diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..54606c6 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,17 @@ +[*] +end_of_line = lf + +[*.cs] +# Indentation +indent_style = space +indent_size = 4 + +# Spacing +csharp_space_after_keywords_in_control_flow_statements = true +csharp_space_between_parentheses = false +csharp_space_around_binary_operators = before_and_after + +# New Lines +csharp_new_line_before_open_brace = all +csharp_new_line_before_else = true +csharp_new_line_before_member_access_on_anonymous_types = true \ No newline at end of file diff --git a/GenOnlineService/Constants.cs b/GenOnlineService/Constants.cs index a0006d3..8bf7573 100644 --- a/GenOnlineService/Constants.cs +++ b/GenOnlineService/Constants.cs @@ -24,6 +24,7 @@ using Org.BouncyCastle.Tls; using System; using System.Collections.Concurrent; +using System.Collections.Frozen; using System.Diagnostics.Metrics; using System.Globalization; using System.Net; @@ -31,2706 +32,2731 @@ using System.Net.WebSockets; using System.Text; using System.Text.Json; +using System.Text.Json.Serialization; using System.Threading.Tasks; using ZstdSharp.Unsafe; namespace GenOnlineService { - public static class Constants - { - public const int GENERALS_ONLINE_VERSION = 1; - public const int GENERALS_ONLINE_NET_VERSION = 1; - public const int GENERALS_ONLINE_SERVICE_VERSION = 1; - - public const UInt16 g_DefaultCameraMaxHeight = 310; - } - public class RoomMember - { - public RoomMember(Int64 a_UserID, string strName, bool admin) - { - UserID = a_UserID; - Name = strName; - IsAdmin = admin; - } - - public Int64 UserID { get; set; } = -1; - public String Name { get; set; } = String.Empty; - public bool IsAdmin { get; set; } = false; - } - - public enum EPendingLoginState - { - None = -1, - Waiting = 0, - LoginSuccess = 1, - LoginFailed = 2 - }; - - public enum EIPVersion - { - IPV4 = 0, - IPV6 - } - - public enum EConnectionStateClient - { - NOT_CONNECTED, - CONNECTING_DIRECT, - FINDING_ROUTE, - CONNECTED_DIRECT, - CONNECTION_FAILED, - CONNECTION_DISCONNECTED - }; - - public enum EConnectionState - { - NOT_CONNECTED, - CONNECTING_DIRECT, - CONNECTING_RELAY, - CONNECTED_DIRECT, - CONNECTED_RELAY, - CONNECTION_FAILED - }; - - public enum ELobbyState - { - UNKNOWN = -1, - GAME_SETUP, - INGAME, - COMPLETE - } - - public enum ERoomFlags : int - { - ROOM_FLAGS_DEFAULT = 0, - ROOM_FLAGS_SHOW_ALL_MATCHES = 1 - } - public class RoomData - { - public int id { get; set; } = -1; - public string name { get; set; } = ""; - public ERoomFlags flags { get; set; } = ERoomFlags.ROOM_FLAGS_DEFAULT; - } - - public class UserSocialContainer - { - public HashSet Friends { get; set; } = new HashSet(); - public HashSet PendingRequests { get; set; } = new HashSet(); - 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 Constants + { + public const int GENERALS_ONLINE_VERSION = 1; + public const int GENERALS_ONLINE_NET_VERSION = 1; + public const int GENERALS_ONLINE_SERVICE_VERSION = 1; - 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 const UInt16 g_DefaultCameraMaxHeight = 310; - 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(AppDbContext _db, EUserSessionType sessionType, bool bIsReconnect, Int64 ownerID, KnownClients.EKnownClients client_id, string ipAddr, string strContinent, string strCountry, double dLatitude, double dLongitude, bool bIsAdmin) - { - string strDisplayName = await Database.Users.GetDisplayName(_db, ownerID); + public static readonly FrozenDictionary Rooms; - // if we have cache data, that means its a reconnect, noraml connections go through login flows which reset cache data - 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 OR shared data, its probably a server restart, so return null - if (userCacheData == null) - { - return null; - } - else - { - // depending on what our ref count was, we MAY need to recreate our shared data - if (m_dictSharedUserData.TryGetValue(ownerID, out SharedUserData? sharedData)) - { - // nothing to do here for shared user data, since the session was abandoned but not fully destroyed, it should still have user data - } - else - { - // get and cache social container - UserSocialContainer socialContainer = new(); - socialContainer.Friends = await Database.Social.GetFriends(_db, ownerID); - socialContainer.PendingRequests = await Database.Social.GetPendingFriendsRequests(_db, ownerID); - socialContainer.Blocked = await Database.Social.GetBlocked(_db, ownerID); + static Constants() + { + string strFileData = System.IO.File.ReadAllText(Path.Combine("data", "rooms.json")); + Rooms = JsonSerializer.Deserialize>(strFileData).ToFrozenDictionary(x => x.id); + } + } - // get stats - PlayerStats GameStats = await Database.UserStats.GetPlayerStats(_db, ownerID); + public class RoomMember + { + public RoomMember(Int64 a_UserID, string strName, bool admin) + { + UserID = a_UserID; + Name = strName; + IsAdmin = admin; + } + + public Int64 UserID { get; set; } = -1; + public String Name { get; set; } = String.Empty; + public bool IsAdmin { get; set; } = false; + } - m_dictSharedUserData[ownerID] = new SharedUserData(ownerID, socialContainer, strDisplayName, bIsAdmin, GameStats); - } + public enum EPendingLoginState + { + None = -1, + Waiting = 0, + LoginSuccess = 1, + LoginFailed = 2 + }; - // clear abandoned flag - userCacheData.MarkNotAbandoned(); - - // NOTE: We intentionally do NOT call ClearPlayerIngameAbandon on reconnect. - // RecordPlayerIngameAbandon only stores the FIRST disconnect time. Clearing - // it here was the root cause of the wrong-winner ELO bug: - // A kills game → RecordPlayerIngameAbandon(A) set - // A relaunches and reconnects → ClearPlayerIngameAbandon(A) wipes the record - // Fallback uses TimeMemberLeft[A] (set much later) > IngameAbandon[B] - // → A incorrectly wins - } - } - else - { - Console.WriteLine("--> WEBSOCKET CONNECT"); - - // how many other sessions do they have online? - bool bIsFirstSessionForUser = WebSocketManager.GetAllDataFromUser(ownerID).Count == 0; - - // kill any existing sessions for this user of same session type - if (m_dictWebsockets[sessionType].TryGetValue(ownerID, out UserWebSocketInstance? existingSession)) - { - Console.WriteLine("Killing existing session for {0} ({1})", ownerID, strDisplayName); - await DeleteSession(ownerID, sessionType, existingSession, !bIsReconnect); - } - - // get and cache social container - UserSocialContainer socialContainer = new(); - socialContainer.Friends = await Database.Social.GetFriends(_db, ownerID); - socialContainer.PendingRequests = await Database.Social.GetPendingFriendsRequests(_db, ownerID); - socialContainer.Blocked = await Database.Social.GetBlocked(_db, ownerID); - - // get stats - PlayerStats GameStats = await Database.UserStats.GetPlayerStats(_db, ownerID); - - 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); - } - } + public enum EIPVersion + { + IPV4 = 0, + IPV6 + } - // 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; + public enum EConnectionStateClient + { + NOT_CONNECTED, + CONNECTING_DIRECT, + FINDING_ROUTE, + CONNECTED_DIRECT, + CONNECTION_FAILED, + CONNECTION_DISCONNECTED + }; + + public enum EConnectionState + { + NOT_CONNECTED, + CONNECTING_DIRECT, + CONNECTING_RELAY, + CONNECTED_DIRECT, + CONNECTED_RELAY, + CONNECTION_FAILED + }; + + public enum ELobbyState + { + UNKNOWN = -1, + GAME_SETUP, + INGAME, + COMPLETE + } - // update last login and last ip - await Database.Users.UpdateLastLoginData(_db, ownerID, ipAddr); + public enum ERoomFlags : int + { + ROOM_FLAGS_DEFAULT = 0, + ROOM_FLAGS_SHOW_ALL_MATCHES = 1 + } + public class RoomData + { + public Int16 id { get; init; } = -1; + + private string m_name = ""; + public string name + { + get => m_name; init + { + m_name = value; + SearchableName = value.Replace(" ", string.Empty).ToLowerInvariant(); + } + } + public ERoomFlags flags { get; init; } = ERoomFlags.ROOM_FLAGS_DEFAULT; + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? exclusionKey { get; init; } = null; + + [JsonIgnore] + public string SearchableName { get; private set; } + } - // TODO_EFCORE: Optimize this, dont iterate all the time - int numSessions = WebSocketManager.GetNumberOfUsersOnline(); + public class UserSocialContainer + { + public HashSet Friends { get; set; } = new HashSet(); + public HashSet PendingRequests { get; set; } = new HashSet(); + public HashSet Blocked { get; set; } = new HashSet(); + } - if (numSessions > g_PeakConnectionCount) - { - g_PeakConnectionCount = numSessions; - } + // NOTE: If you add to the below, make sure you initialize the dictionary + public enum EUserSessionType + { + None = -1, + GameClient = 0, + ChatClient = 1, + GameLauncher = 2 + } - Console.Title = String.Format("GenOnline - {0} players", numSessions); + 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); + } + }); + } + } - SharedUserData? sharedUserData = WebSocketManager.GetSharedDataForUser(ownerID); + public static class WebsocketHelper + { + public static void SendToAllSessionsOfUser(Int64 userID, byte[] bytesData) + { + WebSocketManager.GetAllDataFromUser(userID).ForEach(session => + { + session.QueueWebsocketSend(bytesData); + }); + } + } - // inform the user of any pending friends activities - { - int numOnline = 0; - int numPending = sharedUserData.GetSocialContainer().PendingRequests.Count; - foreach (Int64 friendID in sharedUserData.GetSocialContainer().Friends) - { - if (WebSocketManager.GetSessionFromUser(friendID, sessionType) != null) - { - ++numOnline; - } - } - - if (numOnline > 0 || numPending > 0) - { - WebSocketMessage_FriendsOverallStatusUpdate outboundMsg = new WebSocketMessage_FriendsOverallStatusUpdate(); - outboundMsg.msg_id = (int)EWebSocketMessageID.SOCIAL_FRIENDS_OVERALL_STATUS_UPDATE; - outboundMsg.num_online = numOnline; - outboundMsg.num_pending = numPending; - byte[] bytesJSON = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(outboundMsg)); - await newSess.SendAsync(bytesJSON, WebSocketMessageType.Text); - } - } - return newSess; - } - public static async Task Tick() - { - // Give the entire tick a 20 ms deadline. All users drain concurrently via - // Task.WhenAll, so a slow/stuck client cannot delay others. If the deadline - // fires, the CancellationToken propagates into each in-flight SendAsync and - // 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.SelectMany(inner => inner.Values).Select(sess => sess.TickWebsocket(cts.Token))); - } + 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 + }; + } - public static int GetNumberOfUsersOnline() - { - int numSessions = 0; - foreach (var kvPair in m_dictUserSessions) - { - numSessions += kvPair.Value.Count; - } - return numSessions; - } - public static async Task CheckForTimeouts() - { - List lstSessionsToDestroy = new(); - foreach (var sessionDataByClient in m_dictWebsockets) - { - foreach (var sessionData in sessionDataByClient.Value) - { + // TODO + static class WebSocketManager + { + public static int g_PeakConnectionCount = 0; + public static async Task CreateSession(AppDbContext _db, EUserSessionType sessionType, bool bIsReconnect, Int64 ownerID, KnownClients.EKnownClients client_id, string ipAddr, string strContinent, string strCountry, double dLatitude, double dLongitude, bool bIsAdmin) + { + string strDisplayName = await Database.Users.GetDisplayName(_db, ownerID); + + // if we have cache data, that means its a reconnect, noraml connections go through login flows which reset cache data + UserSession? userCacheData = WebSocketManager.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 OR shared data, its probably a server restart, so return null + if (userCacheData == null) + { + return null; + } + else + { + // depending on what our ref count was, we MAY need to recreate our shared data + if (m_dictSharedUserData.TryGetValue(ownerID, out SharedUserData? sharedData)) + { + // nothing to do here for shared user data, since the session was abandoned but not fully destroyed, it should still have user data + } + else + { + // get and cache social container + UserSocialContainer socialContainer = new(); + socialContainer.Friends = await Database.Social.GetFriends(_db, ownerID); + socialContainer.PendingRequests = await Database.Social.GetPendingFriendsRequests(_db, ownerID); + socialContainer.Blocked = await Database.Social.GetBlocked(_db, ownerID); + + // get stats + PlayerStats GameStats = await Database.UserStats.GetPlayerStats(_db, ownerID); + + m_dictSharedUserData[ownerID] = new SharedUserData(ownerID, socialContainer, strDisplayName, bIsAdmin, GameStats); + } + + // clear abandoned flag + userCacheData.MarkNotAbandoned(); + + // NOTE: We intentionally do NOT call ClearPlayerIngameAbandon on reconnect. + // RecordPlayerIngameAbandon only stores the FIRST disconnect time. Clearing + // it here was the root cause of the wrong-winner ELO bug: + // A kills game → RecordPlayerIngameAbandon(A) set + // A relaunches and reconnects → ClearPlayerIngameAbandon(A) wipes the record + // Fallback uses TimeMemberLeft[A] (set much later) > IngameAbandon[B] + // → A incorrectly wins + } + } + else + { + Console.WriteLine("--> WEBSOCKET CONNECT"); + + // how many other sessions do they have online? + bool bIsFirstSessionForUser = WebSocketManager.GetAllDataFromUser(ownerID).Count == 0; + + // kill any existing sessions for this user of same session type + if (m_dictWebsockets[sessionType].TryGetValue(ownerID, out UserWebSocketInstance? existingSession)) + { + Console.WriteLine("Killing existing session for {0} ({1})", ownerID, strDisplayName); + await DeleteSession(ownerID, sessionType, existingSession, !bIsReconnect); + } + + // get and cache social container + UserSocialContainer socialContainer = new(); + socialContainer.Friends = await Database.Social.GetFriends(_db, ownerID); + socialContainer.PendingRequests = await Database.Social.GetPendingFriendsRequests(_db, ownerID); + socialContainer.Blocked = await Database.Social.GetBlocked(_db, ownerID); + + // get stats + PlayerStats GameStats = await Database.UserStats.GetPlayerStats(_db, ownerID); + + 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); + } + } + + // 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.Users.UpdateLastLoginData(_db, ownerID, ipAddr); + + // TODO_EFCORE: Optimize this, dont iterate all the time + int numSessions = WebSocketManager.GetNumberOfUsersOnline(); + + if (numSessions > g_PeakConnectionCount) + { + g_PeakConnectionCount = numSessions; + } + + Console.Title = String.Format("GenOnline - {0} players", numSessions); + + SharedUserData? sharedUserData = WebSocketManager.GetSharedDataForUser(ownerID); + + // inform the user of any pending friends activities + { + int numOnline = 0; + int numPending = sharedUserData.GetSocialContainer().PendingRequests.Count; + + foreach (Int64 friendID in sharedUserData.GetSocialContainer().Friends) + { + if (WebSocketManager.GetSessionFromUser(friendID, sessionType) != null) + { + ++numOnline; + } + } + + if (numOnline > 0 || numPending > 0) + { + WebSocketMessage_FriendsOverallStatusUpdate outboundMsg = new WebSocketMessage_FriendsOverallStatusUpdate(); + outboundMsg.msg_id = (int)EWebSocketMessageID.SOCIAL_FRIENDS_OVERALL_STATUS_UPDATE; + outboundMsg.num_online = numOnline; + outboundMsg.num_pending = numPending; + byte[] bytesJSON = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(outboundMsg)); + await newSess.SendAsync(bytesJSON, WebSocketMessageType.Text); + } + } + + return newSess; + } + + public static async Task Tick() + { + // Give the entire tick a 20 ms deadline. All users drain concurrently via + // Task.WhenAll, so a slow/stuck client cannot delay others. If the deadline + // fires, the CancellationToken propagates into each in-flight SendAsync and + // 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.SelectMany(inner => inner.Values).Select(sess => sess.TickWebsocket(cts.Token))); + } + + public static int GetNumberOfUsersOnline() + { + int numSessions = 0; + foreach (var kvPair in m_dictUserSessions) + { + numSessions += kvPair.Value.Count; + } + + return numSessions; + } + + public static async Task CheckForTimeouts() + { + List lstSessionsToDestroy = new(); + 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; #endif - 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.m_SessionType, wsSess, false); - } - - // do we need to clear out cache entries? - List> lstCacheEntriesToDestroy = new(); - foreach (var sessionDataPerClientType in m_dictUserSessions) - { - foreach (var sessionData in sessionDataPerClientType.Value) - { - if (sessionData.Value.IsAbandoned()) - { - if (sessionData.Value.NeedsCleanup()) - { - lstCacheEntriesToDestroy.Add(new Tuple(sessionData.Key, sessionData.Value.GetSessionType())); - } - } - } - } - - foreach (Tuple userData in lstCacheEntriesToDestroy) - { - await ClearDataFromUser(userData.Item1, userData.Item2); - } - } - - public static async Task DeleteSession(Int64 user_id, EUserSessionType sessionType, UserWebSocketInstance? oldWS, bool bShouldInvalidatePlayerCacheToBlockReconnect) - { - 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[sessionType].First(kvp => kvp.Value == oldWS); // safe to lookup by sessionType here since we only ever remove old WS of the same type - m_dictWebsockets[sessionType].Remove(item.Key, out UserWebSocketInstance? destroyedSess); - - if (destroyedSess != null) - { - destroyedSess.CloseAsync(WebSocketCloseStatus.NormalClosure, "User signed in from another point of presence [A]"); - } - } - catch - { - - } - } - - if (bShouldInvalidatePlayerCacheToBlockReconnect) - { - WebSocketManager.ClearDataFromUser(user_id, sessionType); - - // 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); - } - } - else - { - // mark it as abandoned for now to start the expiration timer - if (sourceData != null) - { - sourceData.MarkAbandoned(); - - // If the player was in an active game when their connection dropped, record the - // abandon time NOW (before any lobby-structure cleanup runs). This timestamp is - // the authoritative "who quit first" signal used by DetermineLobbyWinnerIfNotPresent, - // and must be captured here rather than in RemoveMember, because RemoveMember may - // execute much later (e.g. 30 s after the first abandonment) or at an unexpected - // time (e.g. when the other player reconnects with a fresh session and their old - // session is forcibly evicted, causing them to appear as "last to leave" even - // though they stayed in the game longer than the opponent who quit first). - try - { - var lobbyManager = ServiceLocator.Services.GetRequiredService(); - if (sourceData.currentLobbyID != -1) - { - Lobby? ingameLobby = lobbyManager.GetLobby(sourceData.currentLobbyID); - if (ingameLobby != null && ingameLobby.State == ELobbyState.INGAME) - { - ingameLobby.RecordPlayerIngameAbandon(user_id); - } - else - { - Console.WriteLine($"[WARN] DeleteSession(false): skipped RecordPlayerIngameAbandon for user {user_id} — lobby={sourceData.currentLobbyID} found={ingameLobby != null} state={ingameLobby?.State}"); - } - } - else - { - Console.WriteLine($"[WARN] DeleteSession(false): skipped RecordPlayerIngameAbandon for user {user_id} — currentLobbyID==-1 (session has no lobby)"); - } - } - catch (Exception ex) - { - Console.WriteLine($"[WARN] Failed to record in-game abandon for user {user_id}: {ex.Message}"); - } - } - } - - // 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 = 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 sourceSharedData.GetSocialContainer().Friends) - { - WebsocketHelper.SendToAllSessionsOfUser(friendID, bytesJSON); - } - } - } - - int numSessions = WebSocketManager.GetNumberOfUsersOnline(); ; - Console.Title = String.Format("GenOnline - {0} players", numSessions); - - try - { - if (oldWS != null) - { - // Close the WS directly, dont rely on session data as it may be linked to something else at this point - await oldWS.CloseAsync(WebSocketCloseStatus.NormalClosure, "Session being deleted"); - } - } - catch - { - - } - } - - /* - public static ChatSession? GetWebSocketForUser(Int64 userID) - { - if (m_dictSessions.TryGetValue(userID, out ChatSession? retVal)) - { - return retVal; - } - else - { - return null; - } - } - */ - - // 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[session.GetSessionType()].TryGetValue(session.m_UserID, out UserWebSocketInstance? retVal)) - { - return retVal; - } - else - { - return null; - } - } - - - 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(); - - public static ConcurrentDictionary> GetUserDataCache() - { - return m_dictUserSessions; - } - - public static SharedUserData? GetSharedDataForUser(string strDisplayName) - { - foreach (var kvPair in m_dictSharedUserData) - { - if (String.Equals(kvPair.Value.m_strDisplayName, strDisplayName, StringComparison.OrdinalIgnoreCase)) - { - return kvPair.Value; - } - } - - return null; - } - - - public static SharedUserData? GetSharedDataForUser(Int64 userID) - { - if (m_dictSharedUserData.TryGetValue(userID, out SharedUserData? retVal)) - { - return retVal; - } - else - { - return null; - } - } - - public static UserSession? GetSessionFromUser(Int64 userID, EUserSessionType sessionType) - { - if (m_dictUserSessions[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[sessionType].ContainsKey(userID)) - { - userData = m_dictUserSessions[sessionType][userID]; - } - await SessionHelpers.FullyDestroyPlayerSession(userID, userData, true); - - // decrement ref count on shared data - if (m_dictSharedUserData.TryGetValue(userID, out SharedUserData? sharedData)) - { - sharedData.DecrementRefCount(); - if (sharedData.NeedsGC()) // cleanup if necessary - { - m_dictSharedUserData.Remove(userID, out var removedSharedData); - } - } - else - { - Console.WriteLine("Error: Could not find shared data for user {0} when deleting session", userID); - } - } - catch - { - - } - - return m_dictUserSessions[sessionType].Remove(userID, out var itemRemoved); - } - - - // helpers - public static async Task SendNewOrDeletedLobbyToAllNetworkRoomMembers(int networkRoomID) - { - if (networkRoomID != -1) - { - // need a member list update - WebSocketMessage_CurrentNetworkRoomLobbyListUpdate lobbyListUpdate = new WebSocketMessage_CurrentNetworkRoomLobbyListUpdate(); - lobbyListUpdate.msg_id = (int)EWebSocketMessageID.NETWORK_ROOM_LOBBY_LIST_UPDATE; - byte[] bytesJSON = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(lobbyListUpdate)); - - // populate list of everyone in the room - foreach (var sessionDataByClient in m_dictUserSessions) - { - foreach (var sessionData in sessionDataByClient.Value) - { - if (sessionData.Value != null) - { - if (sessionData.Value.networkRoomID == networkRoomID || sessionData.Value.networkRoomID == 0) - { - sessionData.Value.QueueWebsocketSend(bytesJSON); - } - } - } - } - } - } - - private static ConcurrentList g_lstDirtyNetworkRooms = new(); - public static async Task TickRoomMemberList() - { - foreach (int roomID in g_lstDirtyNetworkRooms) - { - - // need a member list update - WebSocketMessage_NetworkRoomMemberListUpdate memberListUpdate = new WebSocketMessage_NetworkRoomMemberListUpdate(); - memberListUpdate.msg_id = (int)EWebSocketMessageID.NETWORK_ROOM_MEMBER_LIST_UPDATE; - memberListUpdate.members = new(); - - Dictionary> usersAlreadyProcessed = new(); - // create base - foreach (EUserSessionType sessionType in Enum.GetValues()) - { - usersAlreadyProcessed[sessionType] = new SortedDictionary(); - } - - List lstUsersToSend = new(); - - // populate list of everyone in the room - foreach (var sessionDataByClient in m_dictUserSessions) - { - foreach (var sessionData in sessionDataByClient.Value) - { - UserSession sess = sessionData.Value; - if (sess.networkRoomID == roomID) - { - EUserSessionType sessType = sessionData.Value.GetSessionType(); - if (!usersAlreadyProcessed[sessType].ContainsKey(sess.m_UserID)) - { - usersAlreadyProcessed[sessType][sess.m_UserID] = true; - - 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) - { - if (sessionData.Value.m_client_id == KnownClients.EKnownClients.genhub) - { - strDisplayName += " [GENHUB]"; - } - else - { - strDisplayName += " [LAUNCHER]"; - } - } - else if (sessType == EUserSessionType.ChatClient) - { - strDisplayName += " [WEBCHAT]"; - } - } - - - 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); - } - } - } - } - } - - 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 (Int64 user_id in lstUsersToSend) - { - // 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); - } - } - } - } - - g_lstDirtyNetworkRooms.Clear(); - } - - public static async Task MarkRoomMemberListAsDirty(int roomID) - { - g_lstDirtyNetworkRooms.Add(roomID); - } - } - - // 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 EUserSessionType m_sessionType = EUserSessionType.None; - - private string ACExeCRC = String.Empty; - - // Matchmaking data - public UInt16 MatchmakingPlaylistID = 0; - public ConcurrentList MatchmakingMapIndicies = new(); - - // NOTE: These are not set on login, only when in quickmatch! - public UInt32 ExeCRC = 0; - public UInt32 IniCRC = 0; - public EKnownAnticheatID AnticheatID = EKnownAnticheatID.NONE; - - private ConcurrentList m_lstHistoricMatchIDs = new(); - private ConcurrentDictionary m_lstHistoricMatchIDToSlotIndexMap = new(); - private ConcurrentDictionary m_lstHistoricMatchIDToArmy = new(); - - private Int64 m_timeAbandoned = -1; - - private string m_strMiddlewareUserID = String.Empty; - - public KnownClients.EKnownClients m_client_id = KnownClients.EKnownClients.unknown; - DateTime m_CreateTime = DateTime.Now; - public DateTime GetCreationTime() - { - return m_CreateTime; - } - - public void SetMiddlewareID(string strMiddlewareUserID) - { - m_strMiddlewareUserID = strMiddlewareUserID; - } - - public string GetMiddlewareID() - { - return m_strMiddlewareUserID; - } - - public EUserSessionType GetSessionType() - { - return m_sessionType; - } - - public UInt64 GetLatestMatchID() - { - UInt64 mostRecentMatchID = 0; - if (m_lstHistoricMatchIDs.Count > 0) - { - mostRecentMatchID = m_lstHistoricMatchIDs[m_lstHistoricMatchIDs.Count - 1]; - } - - return mostRecentMatchID; - } - - public TimeSpan GetDuration() - { - return DateTime.Now - m_CreateTime; - } - - 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_strContinent = strContinent; - m_strCountry = strCountry; - m_dLatitude = dLatitude; - m_dLongitude = dLongitude; - - m_UserID = ownerID; - - // store the exe CRC (this is actually the .CODE section, for AC) - if (Helpers.g_dictInitialExeCRCs.ContainsKey(ownerID)) - { - ACExeCRC = Helpers.g_dictInitialExeCRCs[ownerID].ToUpper(); - Helpers.g_dictInitialExeCRCs.Remove(ownerID, out string removedCRC); - } - } - - public void MarkAbandoned() - { - m_timeAbandoned = Environment.TickCount64; - } - public void MarkNotAbandoned() - { - m_timeAbandoned = -1; - } - - public bool IsAbandoned() - { - UserWebSocketInstance websocketForUser = WebSocketManager.GetWebSocketForSession(this); - return m_timeAbandoned != -1 && websocketForUser == null; - } - - // TODO_EFCORE: check all uses of QueueWebsocketSend, some might need to be SendToAllInstances - public void QueueWebsocketSend(byte[] bytesJSON) - { - if (bytesJSON == null) - { - return; - } - - // Always enqueue; the TickWebsocket drain loop is the sole sender, - // ensuring WebSocket.SendAsync is never called concurrently. - m_lstPendingWebsocketSends.Enqueue(bytesJSON); - } - - public async Task CloseWebsocket(WebSocketCloseStatus reason, string strReason) - { - UserWebSocketInstance websocketForUser = WebSocketManager.GetWebSocketForSession(this); - if (websocketForUser != null) - { - await websocketForUser.CloseAsync(reason, strReason); - } - - return websocketForUser; - } - - public async Task TickWebsocket(CancellationToken tickToken = default) - { - // Do we have a connection to send on? - UserWebSocketInstance websocketForUser = WebSocketManager.GetWebSocketForSession(this); - if (websocketForUser != null) - { - const int maxMessagesSendPerFrame = 50; - int messagesSent = 0; - // start dequeing and sending - while (!tickToken.IsCancellationRequested && messagesSent < maxMessagesSendPerFrame && m_lstPendingWebsocketSends.TryDequeue(out byte[] packetData)) - { - await websocketForUser.SendAsync(packetData, WebSocketMessageType.Text, tickToken); - ++messagesSent; - } - } - } - - // TODO_CACHE: Size limit this? - ConcurrentQueue m_lstPendingWebsocketSends = new ConcurrentQueue(); - - public bool NeedsCleanup() - { - const Int64 timeBeforeConsideredAbandoned = 30000; // 5 minutes - return Environment.TickCount64 - m_timeAbandoned >= timeBeforeConsideredAbandoned; - } - - private bool m_bSubscribedToRealtimeSocialupdates = false; - public void SetSubscribedToRealtimeSocialUpdates(bool bSubscribe) - { - m_bSubscribedToRealtimeSocialupdates = bSubscribe; - } - - public bool IsSubscribedToRealtimeSocialUpdates() - { - return m_bSubscribedToRealtimeSocialupdates; - } - - public string GetFullCountryName() - { - RegionInfo ri = new RegionInfo(m_strCountry); - return ri.EnglishName; - } - - public string GetFullContinentName() - { - switch (m_strContinent) - { - case "AF": return "Africa"; - case "AN": return "Antartica"; - case "AS": return "Asia"; - case "EU": return "Europe"; - case "NA": return "North America"; - case "OC": return "Oceania"; - case "SA": return "South America"; - case "T1": return "Tor"; - default: return "Unknown"; - } - } - - // Enough history to cover any realistic upload/outcome window; old entries just waste memory. - private const int MaxHistoricMatches = 50; - - public void RegisterHistoricMatchID(UInt64 matchID, int slotIndex, int army) - { - m_lstHistoricMatchIDs.Add(matchID); - m_lstHistoricMatchIDToSlotIndexMap[matchID] = slotIndex; - m_lstHistoricMatchIDToArmy[matchID] = army; - - // Trim oldest entries so the lists don't grow without bound over long sessions. - while (m_lstHistoricMatchIDs.Count > MaxHistoricMatches) - { - UInt64 oldest = m_lstHistoricMatchIDs[0]; - m_lstHistoricMatchIDs.Remove(oldest); - m_lstHistoricMatchIDToSlotIndexMap.TryRemove(oldest, out _); - m_lstHistoricMatchIDToArmy.TryRemove(oldest, out _); - } - } - - public bool WasPlayerInMatch(UInt64 matchID, out int slotIndexInLobby, out int army) - { - slotIndexInLobby = -1; - army = -1; - - bool bWasInMatch = m_lstHistoricMatchIDs.Contains(matchID); - - if (bWasInMatch) - { - slotIndexInLobby = m_lstHistoricMatchIDToSlotIndexMap[matchID]; - army = m_lstHistoricMatchIDToArmy[matchID]; - - } - - return bWasInMatch; - } - - public async Task UpdateSessionNetworkRoom(Int16 newRoomID) - { - Int16 oldRoom = networkRoomID; - networkRoomID = newRoomID; - - // update the room roster they left - if (oldRoom >= 0) // only if they werent in the dummy room before - { - await WebSocketManager.MarkRoomMemberListAsDirty(oldRoom); - } - - // send update to joiner + everyone in new room already - if (newRoomID >= 0) // only if they actually joined a room and weren't going to the dummy room - { - await WebSocketManager.MarkRoomMemberListAsDirty(newRoomID); - } - - // make the client force refresh list too - await WebSocketManager.SendNewOrDeletedLobbyToAllNetworkRoomMembers(this.networkRoomID); - } - - public void UpdateSessionLobbyID(Int64 newLobbyID) - { - if (m_sessionType == EUserSessionType.GameClient) - { - currentLobbyID = newLobbyID; - } - } - - // network room - public Int16 networkRoomID = -1; - - - // lobby id - public Int64 currentLobbyID = -1; - } - - 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 - - - - // TODO: Start using nullable for int values etc instead of doing 0 or -1 - public async Task SendPong() - { - OnPing(); - - // send pong back - WebSocketMessage_PONG outboundMsg = new WebSocketMessage_PONG(); - outboundMsg.msg_id = (int)EWebSocketMessageID.PONG; - byte[] bytesJSON = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(outboundMsg)); - await SendAsync(bytesJSON, WebSocketMessageType.Text); - } - - - private WebSocket? m_SockInternal = null; - - public UserWebSocketInstance(EUserSessionType sessionType, Int64 ownerID) : base() - { - m_SessionType = sessionType; - m_UserID = ownerID; - } - - public void AttachWebsocket(WebSocket sock) - { - m_SockInternal = sock; - } - - public void OnPing() - { - m_lastPingTime = Environment.TickCount64; - } - - public Int64 GetLastPingTime() - { - return m_lastPingTime; - } - - public Int64 GetTimeSinceLastPing() - { - return Environment.TickCount64 - m_lastPingTime; - } - - public async Task SendAsync(byte[] buffer, WebSocketMessageType messageType, CancellationToken externalToken = default) - { - if (m_SockInternal != null) - { - try - { - // should we chunked send? - /* - const int frameMax = 99999999; - if (buffer.Length > frameMax) - { - int bytresRemaining = buffer.Length; - int numFrames = (int)Math.Ceiling((float)buffer.Length / (float)frameMax); - - System.Diagnostics.Debug.WriteLine("[Websocket] sending {0} bytes in {1} chunks", bytresRemaining, numFrames); - - for (int i = 0; i < numFrames; ++i) - { - int bytesToSend = Math.Min(bytresRemaining, frameMax); - bool bLastFrame = i == numFrames - 1; - - - ArraySegment arrSegment = new ArraySegment(buffer, i * frameMax, bytesToSend); - System.Diagnostics.Debug.WriteLine("[Websocket] send frame {0} with {1} bytes (last: {2})", i, bytesToSend, bLastFrame); - await m_SockInternal.SendAsync(arrSegment, messageType, bLastFrame, CancellationToken.None); - - bytresRemaining -= bytesToSend; - } - - } - else // just send whole - { - await m_SockInternal.SendAsync(buffer, messageType, true, CancellationToken.None); - } - */ - - CancellationTokenSource cts = CancellationTokenSource.CreateLinkedTokenSource(externalToken); - try - { - cts.CancelAfter(TimeSpan.FromMilliseconds(500)); - await m_SockInternal.SendAsync(buffer, messageType, true, cts.Token); - } - finally - { - try - { - cts.Dispose(); - } - catch (ObjectDisposedException) - { - // The linked token may be disposed if the external token (parent CancellationTokenSource) - // fires its timeout while we're disposing this instance. This is safe to ignore. - } - } - } - catch - { - - } - } - } - - public async Task CloseAsync(WebSocketCloseStatus closeStatus, string? statusDescription) - { - if (m_SockInternal != null) - { - try - { - // dont wait forever, certain situations can cause that in ASP.NET - var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); - await m_SockInternal.CloseAsync(closeStatus, statusDescription, cts.Token); - } - catch - { - - } - } - } - } - - public enum ESessionAccessType - { - Authenticate, // log in and out - Social, // friends lists - ServerListReadOnly, // can read lobby list and players etc, but cannot join - StatsReadOnly, // can read stats for any user, but not write anything - Gameplay, // Create lobbies, Anticheat, Middleware login, Matchmaking, match screenshots, replays, join lobby, etc - }; - public static 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(); - await 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 - { - const int numGeneralsEntries = 15; - - public PlayerStats(Int64 inUserID, int inEloRating, int inEloMatches) - { - userID = inUserID; - EloRating = inEloRating; - EloMatches = inEloMatches; - - // init arrays, rest are init'ed below - for (int i = 0; i < numGeneralsEntries; ++i) + 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.m_SessionType, wsSess, false); + } + + // do we need to clear out cache entries? + List> lstCacheEntriesToDestroy = new(); + foreach (var sessionDataPerClientType in m_dictUserSessions) + { + foreach (var sessionData in sessionDataPerClientType.Value) + { + if (sessionData.Value.IsAbandoned()) + { + if (sessionData.Value.NeedsCleanup()) + { + lstCacheEntriesToDestroy.Add(new Tuple(sessionData.Key, sessionData.Value.GetSessionType())); + } + } + } + } + + foreach (Tuple userData in lstCacheEntriesToDestroy) + { + await ClearDataFromUser(userData.Item1, userData.Item2); + } + } + + public static async Task DeleteSession(Int64 user_id, EUserSessionType sessionType, UserWebSocketInstance? oldWS, bool bShouldInvalidatePlayerCacheToBlockReconnect) + { + 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[sessionType].First(kvp => kvp.Value == oldWS); // safe to lookup by sessionType here since we only ever remove old WS of the same type + m_dictWebsockets[sessionType].Remove(item.Key, out UserWebSocketInstance? destroyedSess); + + if (destroyedSess != null) + { + destroyedSess.CloseAsync(WebSocketCloseStatus.NormalClosure, "User signed in from another point of presence [A]"); + } + } + catch + { + + } + } + + if (bShouldInvalidatePlayerCacheToBlockReconnect) + { + WebSocketManager.ClearDataFromUser(user_id, sessionType); + + // 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); + } + } + else + { + // mark it as abandoned for now to start the expiration timer + if (sourceData != null) + { + sourceData.MarkAbandoned(); + + // If the player was in an active game when their connection dropped, record the + // abandon time NOW (before any lobby-structure cleanup runs). This timestamp is + // the authoritative "who quit first" signal used by DetermineLobbyWinnerIfNotPresent, + // and must be captured here rather than in RemoveMember, because RemoveMember may + // execute much later (e.g. 30 s after the first abandonment) or at an unexpected + // time (e.g. when the other player reconnects with a fresh session and their old + // session is forcibly evicted, causing them to appear as "last to leave" even + // though they stayed in the game longer than the opponent who quit first). + try + { + var lobbyManager = ServiceLocator.Services.GetRequiredService(); + if (sourceData.currentLobbyID != -1) + { + Lobby? ingameLobby = lobbyManager.GetLobby(sourceData.currentLobbyID); + if (ingameLobby != null && ingameLobby.State == ELobbyState.INGAME) + { + ingameLobby.RecordPlayerIngameAbandon(user_id); + } + else + { + Console.WriteLine($"[WARN] DeleteSession(false): skipped RecordPlayerIngameAbandon for user {user_id} — lobby={sourceData.currentLobbyID} found={ingameLobby != null} state={ingameLobby?.State}"); + } + } + else + { + Console.WriteLine($"[WARN] DeleteSession(false): skipped RecordPlayerIngameAbandon for user {user_id} — currentLobbyID==-1 (session has no lobby)"); + } + } + catch (Exception ex) + { + Console.WriteLine($"[WARN] Failed to record in-game abandon for user {user_id}: {ex.Message}"); + } + } + } + + // 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 = 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 sourceSharedData.GetSocialContainer().Friends) + { + WebsocketHelper.SendToAllSessionsOfUser(friendID, bytesJSON); + } + } + } + + int numSessions = WebSocketManager.GetNumberOfUsersOnline(); ; + Console.Title = String.Format("GenOnline - {0} players", numSessions); + + try + { + if (oldWS != null) + { + // Close the WS directly, dont rely on session data as it may be linked to something else at this point + await oldWS.CloseAsync(WebSocketCloseStatus.NormalClosure, "Session being deleted"); + } + } + catch + { + + } + } + + /* + public static ChatSession? GetWebSocketForUser(Int64 userID) + { + if (m_dictSessions.TryGetValue(userID, out ChatSession? retVal)) { - losses[i] = 0; - games[i] = 0; - duration[i] = 0; - wins[i] = 0; - unitsKilled[i] = 0; - unitsLost[i] = 0; - unitsBuilt[i] = 0; - buildingsKilled[i] = 0; - buildingsLost[i] = 0; - buildingsBuilt[i] = 0; - earnings[i] = 0; - techCaptured[i] = 0; - discons[i] = 0; - desyncs[i] = 0; - surrenders[i] = 0; - gamesOf2p[i] = 0; - gamesOf3p[i] = 0; - gamesOf4p[i] = 0; - gamesOf5p[i] = 0; - gamesOf6p[i] = 0; - gamesOf7p[i] = 0; - gamesOf8p[i] = 0; - customGames[i] = 0; - QMGames[i] = 0; + return retVal; } - } - - public Int64 userID { get; set; } = -1; - public int EloRating { get; set; } = EloConfig.BaseRating; - public int EloMatches { get; set; } = 0; - - public int[] wins { get; set; } = new int[numGeneralsEntries] { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }; - public int[] losses { get; set; } = new int[numGeneralsEntries] { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }; - public int[] games { get; set; } = new int[numGeneralsEntries] { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }; - public int[] duration { get; set; } = new int[numGeneralsEntries] { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }; - public int[] unitsKilled { get; set; } = new int[numGeneralsEntries] { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }; - public int[] unitsLost { get; set; } = new int[numGeneralsEntries] { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }; - public int[] unitsBuilt { get; set; } = new int[numGeneralsEntries] { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }; - public int[] buildingsKilled { get; set; } = new int[numGeneralsEntries] { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }; - public int[] buildingsLost { get; set; } = new int[numGeneralsEntries] { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }; - public int[] buildingsBuilt { get; set; } = new int[numGeneralsEntries] { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }; - public int[] earnings { get; set; } = new int[numGeneralsEntries] { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }; - public int[] techCaptured { get; set; } = new int[numGeneralsEntries] { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }; - public int[] discons { get; set; } = new int[numGeneralsEntries] { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }; - public int[] desyncs { get; set; } = new int[numGeneralsEntries] { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }; - public int[] surrenders { get; set; } = new int[numGeneralsEntries] { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }; - public int[] gamesOf2p { get; set; } = new int[numGeneralsEntries] { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }; - public int[] gamesOf3p { get; set; } = new int[numGeneralsEntries] { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }; - public int[] gamesOf4p { get; set; } = new int[numGeneralsEntries] { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }; - public int[] gamesOf5p { get; set; } = new int[numGeneralsEntries] { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }; - public int[] gamesOf6p { get; set; } = new int[numGeneralsEntries] { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }; - public int[] gamesOf7p { get; set; } = new int[numGeneralsEntries] { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }; - public int[] gamesOf8p { get; set; } = new int[numGeneralsEntries] { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }; - public int[] customGames { get; set; } = new int[numGeneralsEntries] { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }; - public int[] QMGames { get; set; } = new int[numGeneralsEntries] { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }; - public int[] customGamesPerGeneral { get; set; } = new int[numGeneralsEntries] { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }; - public int[] quickMatchesPerGeneral { get; set; } = new int[numGeneralsEntries] { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }; - public int locale { get; set; } = 0; - public int gamesAsRandom { get; set; } = 0; - public string options { get; set; } = String.Empty; - public string systemSpec { get; set; } = String.Empty; - public float lastFPS { get; set; } = 0F; - public int lastGeneral { get; set; } = 0; - public int gamesInRowWithLastGeneral { get; set; } = 0; - public int challengeMedals { get; set; } = 0; - public int battleHonors { get; set; } = 0; - public int QMwinsInARow { get; set; } = 0; - public int maxQMwinsInARow { get; set; } = 0; - public int winsInARow { get; set; } = 0; - public int maxWinsInARow { get; set; } = 0; - public int lossesInARow { get; set; } = 0; - public int maxLossesInARow { get; set; } = 0; - public int disconsInARow { get; set; } = 0; - public int maxDisconsInARow { get; set; } = 0; - public int desyncsInARow { get; set; } = 0; - public int maxDesyncsInARow { get; set; } = 0; - public int builtParticleCannon { get; set; } = 0; - public int builtNuke { get; set; } = 0; - public int builtSCUD { get; set; } = 0; - public int lastLadderPort { get; set; } = 0; - public string lastLadderHost { get; set; } = String.Empty; - - public void ProcessFromDB(EStatIndex stat_id, int value) - { - int index = (int)stat_id % numGeneralsEntries; - switch (stat_id) + else { - case >= EStatIndex.WINS_PER_GENERAL_0 and <= EStatIndex.WINS_PER_GENERAL_14: - wins[index] = value; - break; - case >= EStatIndex.LOSSES_PER_GENERAL_0 and <= EStatIndex.LOSSES_PER_GENERAL_14: - losses[index] = value; - break; - case >= EStatIndex.GAMES_PER_GENERAL_0 and <= EStatIndex.GAMES_PER_GENERAL_14: - games[index] = value; - break; - case >= EStatIndex.DURATION_PER_GENERAL_0 and <= EStatIndex.DURATION_PER_GENERAL_14: - duration[index] = value; - break; - case >= EStatIndex.UNITSKILLED_PER_GENERAL_0 and <= EStatIndex.UNITSKILLED_PER_GENERAL_14: - unitsKilled[index] = value; - break; - case >= EStatIndex.UNITSLOST_PER_GENERAL_0 and <= EStatIndex.UNITSLOST_PER_GENERAL_14: - unitsLost[index] = value; - break; - case >= EStatIndex.UNITSBUILT_PER_GENERAL_0 and <= EStatIndex.UNITSBUILT_PER_GENERAL_14: - unitsBuilt[index] = value; - break; - case >= EStatIndex.BUILDINGSKILLED_PER_GENERAL_0 and <= EStatIndex.BUILDINGSKILLED_PER_GENERAL_14: - buildingsKilled[index] = value; - break; - case >= EStatIndex.BUILDINGSLOST_PER_GENERAL_0 and <= EStatIndex.BUILDINGSLOST_PER_GENERAL_14: - buildingsLost[index] = value; - break; - case >= EStatIndex.BUILDINGSBUILT_PER_GENERAL_0 and <= EStatIndex.BUILDINGSBUILT_PER_GENERAL_14: - buildingsBuilt[index] = value; - break; - case >= EStatIndex.EARNINGS_PER_GENERAL_0 and <= EStatIndex.EARNINGS_PER_GENERAL_14: - earnings[index] = value; - break; - case >= EStatIndex.TECHCAPTURED_PER_GENERAL_0 and <= EStatIndex.TECHCAPTURED_PER_GENERAL_14: - techCaptured[index] = value; - break; - case >= EStatIndex.DISCONS_PER_GENERAL_0 and <= EStatIndex.DISCONS_PER_GENERAL_14: - discons[index] = value; - break; - case >= EStatIndex.DESYNCS_PER_GENERAL_0 and <= EStatIndex.DESYNCS_PER_GENERAL_14: - desyncs[index] = value; - break; - case >= EStatIndex.SURRENDERS_PER_GENERAL_0 and <= EStatIndex.SURRENDERS_PER_GENERAL_14: - surrenders[index] = value; - break; - case >= EStatIndex.GAMESOF2P_PER_GENERAL_0 and <= EStatIndex.GAMESOF2P_PER_GENERAL_14: - gamesOf2p[index] = value; - break; - case >= EStatIndex.GAMESOF3P_PER_GENERAL_0 and <= EStatIndex.GAMESOF3P_PER_GENERAL_14: - gamesOf3p[index] = value; - break; - case >= EStatIndex.GAMESOF4P_PER_GENERAL_0 and <= EStatIndex.GAMESOF4P_PER_GENERAL_14: - gamesOf4p[index] = value; - break; - case >= EStatIndex.GAMESOF5P_PER_GENERAL_0 and <= EStatIndex.GAMESOF5P_PER_GENERAL_14: - gamesOf5p[index] = value; - break; - case >= EStatIndex.GAMESOF6P_PER_GENERAL_0 and <= EStatIndex.GAMESOF6P_PER_GENERAL_14: - gamesOf6p[index] = value; - break; - case >= EStatIndex.GAMESOF7P_PER_GENERAL_0 and <= EStatIndex.GAMESOF7P_PER_GENERAL_14: - gamesOf7p[index] = value; - break; - case >= EStatIndex.GAMESOF8P_PER_GENERAL_0 and <= EStatIndex.GAMESOF8P_PER_GENERAL_14: - gamesOf8p[index] = value; - break; - case >= EStatIndex.CUSTOMGAMES_PER_GENERAL_0 and <= EStatIndex.CUSTOMGAMES_PER_GENERAL_14: - customGamesPerGeneral[index] = value; - break; - case >= EStatIndex.QUICKMATCHES_PER_GENERAL_0 and <= EStatIndex.QUICKMATCHES_PER_GENERAL_14: - quickMatchesPerGeneral[index] = value; - break; - case EStatIndex.LOCALE: - locale = value; - break; - case EStatIndex.GAMES_AS_RANDOM: - gamesAsRandom = value; - break; - case EStatIndex.LASTFPS: - lastFPS = value; - break; - case EStatIndex.LASTGENERAL: - lastGeneral = value; - break; - case EStatIndex.GAMESINROWWITHLASTGENERAL: - gamesInRowWithLastGeneral = value; - break; - case EStatIndex.CHALLENGEMEDALS: - challengeMedals = value; - break; - case EStatIndex.BATTLEHONORS: - battleHonors = value; - break; - case EStatIndex.QMWINSINAROW: - QMwinsInARow = value; - break; - case EStatIndex.MAXQMWINSINAROW: - maxQMwinsInARow = value; - break; - case EStatIndex.WINSINAROW: - winsInARow = value; - break; - case EStatIndex.MAXWINSINAROW: - maxWinsInARow = value; - break; - case EStatIndex.LOSSESINAROW: - lossesInARow = value; - break; - case EStatIndex.MAXLOSSESINAROW: - maxLossesInARow = value; - break; - case EStatIndex.DISCONSINAROW: - disconsInARow = value; - break; - case EStatIndex.MAXDISCONSINAROW: - maxDisconsInARow = value; - break; - case EStatIndex.DESYNCSINAROW: - desyncsInARow = value; - break; - case EStatIndex.MAXDESYNCSINAROW: - maxDesyncsInARow = value; - break; - case EStatIndex.BUILTPARTICLECANNON: - builtParticleCannon = value; - break; - case EStatIndex.BUILTNUKE: - builtNuke = value; - break; - case EStatIndex.BUILTSCUD: - builtSCUD = value; - break; - case EStatIndex.LASTLADDERPORT: - lastLadderPort = value; - break; - default: - throw new ArgumentOutOfRangeException(nameof(stat_id), $"Unhandled stat_id: {stat_id}"); + return null; } } - } - - public enum EStatIndex : UInt16 - { - WINS_PER_GENERAL_0, - WINS_PER_GENERAL_1, - WINS_PER_GENERAL_2, - WINS_PER_GENERAL_3, - WINS_PER_GENERAL_4, - WINS_PER_GENERAL_5, - WINS_PER_GENERAL_6, - WINS_PER_GENERAL_7, - WINS_PER_GENERAL_8, - WINS_PER_GENERAL_9, - WINS_PER_GENERAL_10, - WINS_PER_GENERAL_11, - WINS_PER_GENERAL_12, - WINS_PER_GENERAL_13, - WINS_PER_GENERAL_14, - - LOSSES_PER_GENERAL_0, - LOSSES_PER_GENERAL_1, - LOSSES_PER_GENERAL_2, - LOSSES_PER_GENERAL_3, - LOSSES_PER_GENERAL_4, - LOSSES_PER_GENERAL_5, - LOSSES_PER_GENERAL_6, - LOSSES_PER_GENERAL_7, - LOSSES_PER_GENERAL_8, - LOSSES_PER_GENERAL_9, - LOSSES_PER_GENERAL_10, - LOSSES_PER_GENERAL_11, - LOSSES_PER_GENERAL_12, - LOSSES_PER_GENERAL_13, - LOSSES_PER_GENERAL_14, - - GAMES_PER_GENERAL_0, - GAMES_PER_GENERAL_1, - GAMES_PER_GENERAL_2, - GAMES_PER_GENERAL_3, - GAMES_PER_GENERAL_4, - GAMES_PER_GENERAL_5, - GAMES_PER_GENERAL_6, - GAMES_PER_GENERAL_7, - GAMES_PER_GENERAL_8, - GAMES_PER_GENERAL_9, - GAMES_PER_GENERAL_10, - GAMES_PER_GENERAL_11, - GAMES_PER_GENERAL_12, - GAMES_PER_GENERAL_13, - GAMES_PER_GENERAL_14, - - DURATION_PER_GENERAL_0, - DURATION_PER_GENERAL_1, - DURATION_PER_GENERAL_2, - DURATION_PER_GENERAL_3, - DURATION_PER_GENERAL_4, - DURATION_PER_GENERAL_5, - DURATION_PER_GENERAL_6, - DURATION_PER_GENERAL_7, - DURATION_PER_GENERAL_8, - DURATION_PER_GENERAL_9, - DURATION_PER_GENERAL_10, - DURATION_PER_GENERAL_11, - DURATION_PER_GENERAL_12, - DURATION_PER_GENERAL_13, - DURATION_PER_GENERAL_14, - - UNITSKILLED_PER_GENERAL_0, - UNITSKILLED_PER_GENERAL_1, - UNITSKILLED_PER_GENERAL_2, - UNITSKILLED_PER_GENERAL_3, - UNITSKILLED_PER_GENERAL_4, - UNITSKILLED_PER_GENERAL_5, - UNITSKILLED_PER_GENERAL_6, - UNITSKILLED_PER_GENERAL_7, - UNITSKILLED_PER_GENERAL_8, - UNITSKILLED_PER_GENERAL_9, - UNITSKILLED_PER_GENERAL_10, - UNITSKILLED_PER_GENERAL_11, - UNITSKILLED_PER_GENERAL_12, - UNITSKILLED_PER_GENERAL_13, - UNITSKILLED_PER_GENERAL_14, - - UNITSLOST_PER_GENERAL_0, - UNITSLOST_PER_GENERAL_1, - UNITSLOST_PER_GENERAL_2, - UNITSLOST_PER_GENERAL_3, - UNITSLOST_PER_GENERAL_4, - UNITSLOST_PER_GENERAL_5, - UNITSLOST_PER_GENERAL_6, - UNITSLOST_PER_GENERAL_7, - UNITSLOST_PER_GENERAL_8, - UNITSLOST_PER_GENERAL_9, - UNITSLOST_PER_GENERAL_10, - UNITSLOST_PER_GENERAL_11, - UNITSLOST_PER_GENERAL_12, - UNITSLOST_PER_GENERAL_13, - UNITSLOST_PER_GENERAL_14, - - UNITSBUILT_PER_GENERAL_0, - UNITSBUILT_PER_GENERAL_1, - UNITSBUILT_PER_GENERAL_2, - UNITSBUILT_PER_GENERAL_3, - UNITSBUILT_PER_GENERAL_4, - UNITSBUILT_PER_GENERAL_5, - UNITSBUILT_PER_GENERAL_6, - UNITSBUILT_PER_GENERAL_7, - UNITSBUILT_PER_GENERAL_8, - UNITSBUILT_PER_GENERAL_9, - UNITSBUILT_PER_GENERAL_10, - UNITSBUILT_PER_GENERAL_11, - UNITSBUILT_PER_GENERAL_12, - UNITSBUILT_PER_GENERAL_13, - UNITSBUILT_PER_GENERAL_14, - - BUILDINGSKILLED_PER_GENERAL_0, - BUILDINGSKILLED_PER_GENERAL_1, - BUILDINGSKILLED_PER_GENERAL_2, - BUILDINGSKILLED_PER_GENERAL_3, - BUILDINGSKILLED_PER_GENERAL_4, - BUILDINGSKILLED_PER_GENERAL_5, - BUILDINGSKILLED_PER_GENERAL_6, - BUILDINGSKILLED_PER_GENERAL_7, - BUILDINGSKILLED_PER_GENERAL_8, - BUILDINGSKILLED_PER_GENERAL_9, - BUILDINGSKILLED_PER_GENERAL_10, - BUILDINGSKILLED_PER_GENERAL_11, - BUILDINGSKILLED_PER_GENERAL_12, - BUILDINGSKILLED_PER_GENERAL_13, - BUILDINGSKILLED_PER_GENERAL_14, - - BUILDINGSLOST_PER_GENERAL_0, - BUILDINGSLOST_PER_GENERAL_1, - BUILDINGSLOST_PER_GENERAL_2, - BUILDINGSLOST_PER_GENERAL_3, - BUILDINGSLOST_PER_GENERAL_4, - BUILDINGSLOST_PER_GENERAL_5, - BUILDINGSLOST_PER_GENERAL_6, - BUILDINGSLOST_PER_GENERAL_7, - BUILDINGSLOST_PER_GENERAL_8, - BUILDINGSLOST_PER_GENERAL_9, - BUILDINGSLOST_PER_GENERAL_10, - BUILDINGSLOST_PER_GENERAL_11, - BUILDINGSLOST_PER_GENERAL_12, - BUILDINGSLOST_PER_GENERAL_13, - BUILDINGSLOST_PER_GENERAL_14, - - BUILDINGSBUILT_PER_GENERAL_0, - BUILDINGSBUILT_PER_GENERAL_1, - BUILDINGSBUILT_PER_GENERAL_2, - BUILDINGSBUILT_PER_GENERAL_3, - BUILDINGSBUILT_PER_GENERAL_4, - BUILDINGSBUILT_PER_GENERAL_5, - BUILDINGSBUILT_PER_GENERAL_6, - BUILDINGSBUILT_PER_GENERAL_7, - BUILDINGSBUILT_PER_GENERAL_8, - BUILDINGSBUILT_PER_GENERAL_9, - BUILDINGSBUILT_PER_GENERAL_10, - BUILDINGSBUILT_PER_GENERAL_11, - BUILDINGSBUILT_PER_GENERAL_12, - BUILDINGSBUILT_PER_GENERAL_13, - BUILDINGSBUILT_PER_GENERAL_14, - - EARNINGS_PER_GENERAL_0, - EARNINGS_PER_GENERAL_1, - EARNINGS_PER_GENERAL_2, - EARNINGS_PER_GENERAL_3, - EARNINGS_PER_GENERAL_4, - EARNINGS_PER_GENERAL_5, - EARNINGS_PER_GENERAL_6, - EARNINGS_PER_GENERAL_7, - EARNINGS_PER_GENERAL_8, - EARNINGS_PER_GENERAL_9, - EARNINGS_PER_GENERAL_10, - EARNINGS_PER_GENERAL_11, - EARNINGS_PER_GENERAL_12, - EARNINGS_PER_GENERAL_13, - EARNINGS_PER_GENERAL_14, - - TECHCAPTURED_PER_GENERAL_0, - TECHCAPTURED_PER_GENERAL_1, - TECHCAPTURED_PER_GENERAL_2, - TECHCAPTURED_PER_GENERAL_3, - TECHCAPTURED_PER_GENERAL_4, - TECHCAPTURED_PER_GENERAL_5, - TECHCAPTURED_PER_GENERAL_6, - TECHCAPTURED_PER_GENERAL_7, - TECHCAPTURED_PER_GENERAL_8, - TECHCAPTURED_PER_GENERAL_9, - TECHCAPTURED_PER_GENERAL_10, - TECHCAPTURED_PER_GENERAL_11, - TECHCAPTURED_PER_GENERAL_12, - TECHCAPTURED_PER_GENERAL_13, - TECHCAPTURED_PER_GENERAL_14, - - DISCONS_PER_GENERAL_0, - DISCONS_PER_GENERAL_1, - DISCONS_PER_GENERAL_2, - DISCONS_PER_GENERAL_3, - DISCONS_PER_GENERAL_4, - DISCONS_PER_GENERAL_5, - DISCONS_PER_GENERAL_6, - DISCONS_PER_GENERAL_7, - DISCONS_PER_GENERAL_8, - DISCONS_PER_GENERAL_9, - DISCONS_PER_GENERAL_10, - DISCONS_PER_GENERAL_11, - DISCONS_PER_GENERAL_12, - DISCONS_PER_GENERAL_13, - DISCONS_PER_GENERAL_14, - - DESYNCS_PER_GENERAL_0, - DESYNCS_PER_GENERAL_1, - DESYNCS_PER_GENERAL_2, - DESYNCS_PER_GENERAL_3, - DESYNCS_PER_GENERAL_4, - DESYNCS_PER_GENERAL_5, - DESYNCS_PER_GENERAL_6, - DESYNCS_PER_GENERAL_7, - DESYNCS_PER_GENERAL_8, - DESYNCS_PER_GENERAL_9, - DESYNCS_PER_GENERAL_10, - DESYNCS_PER_GENERAL_11, - DESYNCS_PER_GENERAL_12, - DESYNCS_PER_GENERAL_13, - DESYNCS_PER_GENERAL_14, - - SURRENDERS_PER_GENERAL_0, - SURRENDERS_PER_GENERAL_1, - SURRENDERS_PER_GENERAL_2, - SURRENDERS_PER_GENERAL_3, - SURRENDERS_PER_GENERAL_4, - SURRENDERS_PER_GENERAL_5, - SURRENDERS_PER_GENERAL_6, - SURRENDERS_PER_GENERAL_7, - SURRENDERS_PER_GENERAL_8, - SURRENDERS_PER_GENERAL_9, - SURRENDERS_PER_GENERAL_10, - SURRENDERS_PER_GENERAL_11, - SURRENDERS_PER_GENERAL_12, - SURRENDERS_PER_GENERAL_13, - SURRENDERS_PER_GENERAL_14, - - GAMESOF2P_PER_GENERAL_0, - GAMESOF2P_PER_GENERAL_1, - GAMESOF2P_PER_GENERAL_2, - GAMESOF2P_PER_GENERAL_3, - GAMESOF2P_PER_GENERAL_4, - GAMESOF2P_PER_GENERAL_5, - GAMESOF2P_PER_GENERAL_6, - GAMESOF2P_PER_GENERAL_7, - GAMESOF2P_PER_GENERAL_8, - GAMESOF2P_PER_GENERAL_9, - GAMESOF2P_PER_GENERAL_10, - GAMESOF2P_PER_GENERAL_11, - GAMESOF2P_PER_GENERAL_12, - GAMESOF2P_PER_GENERAL_13, - GAMESOF2P_PER_GENERAL_14, - - GAMESOF3P_PER_GENERAL_0, - GAMESOF3P_PER_GENERAL_1, - GAMESOF3P_PER_GENERAL_2, - GAMESOF3P_PER_GENERAL_3, - GAMESOF3P_PER_GENERAL_4, - GAMESOF3P_PER_GENERAL_5, - GAMESOF3P_PER_GENERAL_6, - GAMESOF3P_PER_GENERAL_7, - GAMESOF3P_PER_GENERAL_8, - GAMESOF3P_PER_GENERAL_9, - GAMESOF3P_PER_GENERAL_10, - GAMESOF3P_PER_GENERAL_11, - GAMESOF3P_PER_GENERAL_12, - GAMESOF3P_PER_GENERAL_13, - GAMESOF3P_PER_GENERAL_14, - - GAMESOF4P_PER_GENERAL_0, - GAMESOF4P_PER_GENERAL_1, - GAMESOF4P_PER_GENERAL_2, - GAMESOF4P_PER_GENERAL_3, - GAMESOF4P_PER_GENERAL_4, - GAMESOF4P_PER_GENERAL_5, - GAMESOF4P_PER_GENERAL_6, - GAMESOF4P_PER_GENERAL_7, - GAMESOF4P_PER_GENERAL_8, - GAMESOF4P_PER_GENERAL_9, - GAMESOF4P_PER_GENERAL_10, - GAMESOF4P_PER_GENERAL_11, - GAMESOF4P_PER_GENERAL_12, - GAMESOF4P_PER_GENERAL_13, - GAMESOF4P_PER_GENERAL_14, - - GAMESOF5P_PER_GENERAL_0, - GAMESOF5P_PER_GENERAL_1, - GAMESOF5P_PER_GENERAL_2, - GAMESOF5P_PER_GENERAL_3, - GAMESOF5P_PER_GENERAL_4, - GAMESOF5P_PER_GENERAL_5, - GAMESOF5P_PER_GENERAL_6, - GAMESOF5P_PER_GENERAL_7, - GAMESOF5P_PER_GENERAL_8, - GAMESOF5P_PER_GENERAL_9, - GAMESOF5P_PER_GENERAL_10, - GAMESOF5P_PER_GENERAL_11, - GAMESOF5P_PER_GENERAL_12, - GAMESOF5P_PER_GENERAL_13, - GAMESOF5P_PER_GENERAL_14, - - GAMESOF6P_PER_GENERAL_0, - GAMESOF6P_PER_GENERAL_1, - GAMESOF6P_PER_GENERAL_2, - GAMESOF6P_PER_GENERAL_3, - GAMESOF6P_PER_GENERAL_4, - GAMESOF6P_PER_GENERAL_5, - GAMESOF6P_PER_GENERAL_6, - GAMESOF6P_PER_GENERAL_7, - GAMESOF6P_PER_GENERAL_8, - GAMESOF6P_PER_GENERAL_9, - GAMESOF6P_PER_GENERAL_10, - GAMESOF6P_PER_GENERAL_11, - GAMESOF6P_PER_GENERAL_12, - GAMESOF6P_PER_GENERAL_13, - GAMESOF6P_PER_GENERAL_14, - - GAMESOF7P_PER_GENERAL_0, - GAMESOF7P_PER_GENERAL_1, - GAMESOF7P_PER_GENERAL_2, - GAMESOF7P_PER_GENERAL_3, - GAMESOF7P_PER_GENERAL_4, - GAMESOF7P_PER_GENERAL_5, - GAMESOF7P_PER_GENERAL_6, - GAMESOF7P_PER_GENERAL_7, - GAMESOF7P_PER_GENERAL_8, - GAMESOF7P_PER_GENERAL_9, - GAMESOF7P_PER_GENERAL_10, - GAMESOF7P_PER_GENERAL_11, - GAMESOF7P_PER_GENERAL_12, - GAMESOF7P_PER_GENERAL_13, - GAMESOF7P_PER_GENERAL_14, - - GAMESOF8P_PER_GENERAL_0, - GAMESOF8P_PER_GENERAL_1, - GAMESOF8P_PER_GENERAL_2, - GAMESOF8P_PER_GENERAL_3, - GAMESOF8P_PER_GENERAL_4, - GAMESOF8P_PER_GENERAL_5, - GAMESOF8P_PER_GENERAL_6, - GAMESOF8P_PER_GENERAL_7, - GAMESOF8P_PER_GENERAL_8, - GAMESOF8P_PER_GENERAL_9, - GAMESOF8P_PER_GENERAL_10, - GAMESOF8P_PER_GENERAL_11, - GAMESOF8P_PER_GENERAL_12, - GAMESOF8P_PER_GENERAL_13, - GAMESOF8P_PER_GENERAL_14, - - CUSTOMGAMES_PER_GENERAL_0, - CUSTOMGAMES_PER_GENERAL_1, - CUSTOMGAMES_PER_GENERAL_2, - CUSTOMGAMES_PER_GENERAL_3, - CUSTOMGAMES_PER_GENERAL_4, - CUSTOMGAMES_PER_GENERAL_5, - CUSTOMGAMES_PER_GENERAL_6, - CUSTOMGAMES_PER_GENERAL_7, - CUSTOMGAMES_PER_GENERAL_8, - CUSTOMGAMES_PER_GENERAL_9, - CUSTOMGAMES_PER_GENERAL_10, - CUSTOMGAMES_PER_GENERAL_11, - CUSTOMGAMES_PER_GENERAL_12, - CUSTOMGAMES_PER_GENERAL_13, - CUSTOMGAMES_PER_GENERAL_14, - - QUICKMATCHES_PER_GENERAL_0, - QUICKMATCHES_PER_GENERAL_1, - QUICKMATCHES_PER_GENERAL_2, - QUICKMATCHES_PER_GENERAL_3, - QUICKMATCHES_PER_GENERAL_4, - QUICKMATCHES_PER_GENERAL_5, - QUICKMATCHES_PER_GENERAL_6, - QUICKMATCHES_PER_GENERAL_7, - QUICKMATCHES_PER_GENERAL_8, - QUICKMATCHES_PER_GENERAL_9, - QUICKMATCHES_PER_GENERAL_10, - QUICKMATCHES_PER_GENERAL_11, - QUICKMATCHES_PER_GENERAL_12, - QUICKMATCHES_PER_GENERAL_13, - QUICKMATCHES_PER_GENERAL_14, - - LOCALE, - GAMES_AS_RANDOM, - OPTIONS, - SYSTEM_SPEC, - LASTFPS, - LASTGENERAL, - GAMESINROWWITHLASTGENERAL, - CHALLENGEMEDALS, - BATTLEHONORS, - QMWINSINAROW, - MAXQMWINSINAROW, - WINSINAROW, - MAXWINSINAROW, - LOSSESINAROW, - MAXLOSSESINAROW, - DISCONSINAROW, - - MAXDISCONSINAROW, - DESYNCSINAROW, - MAXDESYNCSINAROW, - BUILTPARTICLECANNON, - BUILTNUKE, - BUILTSCUD, - LASTLADDERPORT, - LASTLADDERHOST - } - - public class TURNCredentialContainer - { - public TURNCredentialContainer(string strUsername, string strToken) - { - m_strToken = strToken; - m_strUsername = strUsername; - } + */ - public string m_strUsername; - public string m_strToken; - } + // 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[session.GetSessionType()].TryGetValue(session.m_UserID, out UserWebSocketInstance? retVal)) + { + return retVal; + } + else + { + return null; + } + } + + + 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(); + + public static ConcurrentDictionary> GetUserDataCache() + { + return m_dictUserSessions; + } + + public static SharedUserData? GetSharedDataForUser(string strDisplayName) + { + foreach (var kvPair in m_dictSharedUserData) + { + if (String.Equals(kvPair.Value.m_strDisplayName, strDisplayName, StringComparison.OrdinalIgnoreCase)) + { + return kvPair.Value; + } + } + + return null; + } + + + public static SharedUserData? GetSharedDataForUser(Int64 userID) + { + if (m_dictSharedUserData.TryGetValue(userID, out SharedUserData? retVal)) + { + return retVal; + } + else + { + return null; + } + } + + public static UserSession? GetSessionFromUser(Int64 userID, EUserSessionType sessionType) + { + if (m_dictUserSessions[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[sessionType].ContainsKey(userID)) + { + userData = m_dictUserSessions[sessionType][userID]; + } + await SessionHelpers.FullyDestroyPlayerSession(userID, userData, true); + + // decrement ref count on shared data + if (m_dictSharedUserData.TryGetValue(userID, out SharedUserData? sharedData)) + { + sharedData.DecrementRefCount(); + if (sharedData.NeedsGC()) // cleanup if necessary + { + m_dictSharedUserData.Remove(userID, out var removedSharedData); + } + } + else + { + Console.WriteLine("Error: Could not find shared data for user {0} when deleting session", userID); + } + } + catch + { + + } + + return m_dictUserSessions[sessionType].Remove(userID, out var itemRemoved); + } + + + // helpers + public static async Task SendNewOrDeletedLobbyToAllNetworkRoomMembers(int networkRoomID) + { + if (networkRoomID != -1) + { + // need a member list update + WebSocketMessage_CurrentNetworkRoomLobbyListUpdate lobbyListUpdate = new WebSocketMessage_CurrentNetworkRoomLobbyListUpdate(); + lobbyListUpdate.msg_id = (int)EWebSocketMessageID.NETWORK_ROOM_LOBBY_LIST_UPDATE; + byte[] bytesJSON = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(lobbyListUpdate)); + + // populate list of everyone in the room + foreach (var sessionDataByClient in m_dictUserSessions) + { + foreach (var sessionData in sessionDataByClient.Value) + { + if (sessionData.Value != null) + { + if (sessionData.Value.networkRoomID == networkRoomID || sessionData.Value.networkRoomID == 0) + { + sessionData.Value.QueueWebsocketSend(bytesJSON); + } + } + } + } + } + } + + private static ConcurrentList g_lstDirtyNetworkRooms = new(); + public static async Task TickRoomMemberList() + { + foreach (int roomID in g_lstDirtyNetworkRooms) + { + + // need a member list update + WebSocketMessage_NetworkRoomMemberListUpdate memberListUpdate = new WebSocketMessage_NetworkRoomMemberListUpdate(); + memberListUpdate.msg_id = (int)EWebSocketMessageID.NETWORK_ROOM_MEMBER_LIST_UPDATE; + memberListUpdate.members = new(); + + Dictionary> usersAlreadyProcessed = new(); + // create base + foreach (EUserSessionType sessionType in Enum.GetValues()) + { + usersAlreadyProcessed[sessionType] = new SortedDictionary(); + } + + List lstUsersToSend = new(); + + // populate list of everyone in the room + foreach (var sessionDataByClient in m_dictUserSessions) + { + foreach (var sessionData in sessionDataByClient.Value) + { + UserSession sess = sessionData.Value; + if (sess.networkRoomID == roomID) + { + EUserSessionType sessType = sessionData.Value.GetSessionType(); + if (!usersAlreadyProcessed[sessType].ContainsKey(sess.m_UserID)) + { + usersAlreadyProcessed[sessType][sess.m_UserID] = true; + + 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) + { + if (sessionData.Value.m_client_id == KnownClients.EKnownClients.genhub) + { + strDisplayName += " [GENHUB]"; + } + else + { + strDisplayName += " [LAUNCHER]"; + } + } + else if (sessType == EUserSessionType.ChatClient) + { + strDisplayName += " [WEBCHAT]"; + } + } + + + 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); + } + } + } + } + } + + 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 (Int64 user_id in lstUsersToSend) + { + // 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); + } + } + } + } + + g_lstDirtyNetworkRooms.Clear(); + } + + public static async Task MarkRoomMemberListAsDirty(int roomID) + { + g_lstDirtyNetworkRooms.Add(roomID); + } + } - public class iceEntry - { - public List? urls { get; set; } = null; - public string? username { get; set; } = null; - public string? credential { get; set; } = null; - } + // 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 class TURNResponse - { - public List? iceServers { get; set; } - } + public void IncrementRefCount() + { + Interlocked.Increment(ref m_RefCount); + } - public static class S3CredentialManager - { - private static AmazonS3Client m_s3client = null; + public void DecrementRefCount() + { + Interlocked.Decrement(ref m_RefCount); + } - public static void Initialize() - { - GetS3Config(out int TTL, out string access_key, out string secret_key, out string bucket_name, out string client_endpoint); + public bool NeedsGC() + { + return m_RefCount <= 0; + } - var config = new AmazonS3Config - { - ServiceURL = client_endpoint, - ForcePathStyle = true - }; + public Int64 m_UserID = -1; + public string m_strDisplayName = String.Empty; + private bool m_bIsAdmin; - m_s3client = new AmazonS3Client(access_key, secret_key, config); - } + // contains ELO too + public PlayerStats? GameStats { get; private set; } = null; - private static void GetS3Config(out int TTL, out string access_key, out string secret_key, out string bucket_name, out string endpoint) - { - TTL = -1; - access_key = String.Empty; - secret_key = String.Empty; - bucket_name = String.Empty; - endpoint = String.Empty; + private UserSocialContainer m_socialContainer; - if (Program.g_Config == null) - { - throw new Exception("Config not loaded"); - } + public UserSocialContainer GetSocialContainer() { return m_socialContainer; } - IConfigurationSection? turnSettings = Program.g_Config.GetSection("MatchData"); + public bool IsAdmin() { return m_bIsAdmin; } - if (turnSettings == null) - { - throw new Exception("MatchData section missing in config"); - } + public SharedUserData(Int64 ownerID, UserSocialContainer socialContainer, string strDisplayName, bool bIsAdmin, PlayerStats userStats) + { + m_strDisplayName = strDisplayName; + m_bIsAdmin = bIsAdmin; - string? s3_access_key = turnSettings.GetValue("s3_access_key"); - string? s3_secret_key = turnSettings.GetValue("s3_secret_key"); - string? s3_bucket_name = turnSettings.GetValue("s3_bucket_name"); - string? s3_endpoint = turnSettings.GetValue("s3_endpoint"); - int? s3_url_ttl_minutes = turnSettings.GetValue("s3_url_ttl_minutes"); + m_UserID = ownerID; - if (s3_access_key == null) - { - throw new Exception("s3_access_key missing in config"); - } + m_socialContainer = socialContainer; - if (s3_secret_key == null) - { - throw new Exception("s3_secret_key missing in config"); - } + GameStats = userStats; - if (s3_bucket_name == null) - { - throw new Exception("s3_bucket_name missing in config"); - } + // upon creation, immediately increment ref count + IncrementRefCount(); + } + } - if (s3_endpoint == null) - { - throw new Exception("s3_endpoint missing in config"); - } + 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 EUserSessionType m_sessionType = EUserSessionType.None; + + private string ACExeCRC = String.Empty; + + // Matchmaking data + public UInt16 MatchmakingPlaylistID = 0; + public ConcurrentList MatchmakingMapIndicies = new(); + + // NOTE: These are not set on login, only when in quickmatch! + public UInt32 ExeCRC = 0; + public UInt32 IniCRC = 0; + public EKnownAnticheatID AnticheatID = EKnownAnticheatID.NONE; + + private ConcurrentList m_lstHistoricMatchIDs = new(); + private ConcurrentDictionary m_lstHistoricMatchIDToSlotIndexMap = new(); + private ConcurrentDictionary m_lstHistoricMatchIDToArmy = new(); + + private Int64 m_timeAbandoned = -1; + + private string m_strMiddlewareUserID = String.Empty; + + public KnownClients.EKnownClients m_client_id = KnownClients.EKnownClients.unknown; + DateTime m_CreateTime = DateTime.Now; + public DateTime GetCreationTime() + { + return m_CreateTime; + } + + public void SetMiddlewareID(string strMiddlewareUserID) + { + m_strMiddlewareUserID = strMiddlewareUserID; + } + + public string GetMiddlewareID() + { + return m_strMiddlewareUserID; + } + + public EUserSessionType GetSessionType() + { + return m_sessionType; + } + + public UInt64 GetLatestMatchID() + { + UInt64 mostRecentMatchID = 0; + if (m_lstHistoricMatchIDs.Count > 0) + { + mostRecentMatchID = m_lstHistoricMatchIDs[m_lstHistoricMatchIDs.Count - 1]; + } + + return mostRecentMatchID; + } + + public TimeSpan GetDuration() + { + return DateTime.Now - m_CreateTime; + } + + 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_strContinent = strContinent; + m_strCountry = strCountry; + m_dLatitude = dLatitude; + m_dLongitude = dLongitude; + + m_UserID = ownerID; + + // store the exe CRC (this is actually the .CODE section, for AC) + if (Helpers.g_dictInitialExeCRCs.ContainsKey(ownerID)) + { + ACExeCRC = Helpers.g_dictInitialExeCRCs[ownerID].ToUpper(); + Helpers.g_dictInitialExeCRCs.Remove(ownerID, out string removedCRC); + } + } + + public void MarkAbandoned() + { + m_timeAbandoned = Environment.TickCount64; + } + public void MarkNotAbandoned() + { + m_timeAbandoned = -1; + } + + public bool IsAbandoned() + { + UserWebSocketInstance websocketForUser = WebSocketManager.GetWebSocketForSession(this); + return m_timeAbandoned != -1 && websocketForUser == null; + } + + // TODO_EFCORE: check all uses of QueueWebsocketSend, some might need to be SendToAllInstances + public void QueueWebsocketSend(byte[] bytesJSON) + { + if (bytesJSON == null) + { + return; + } + + // Always enqueue; the TickWebsocket drain loop is the sole sender, + // ensuring WebSocket.SendAsync is never called concurrently. + m_lstPendingWebsocketSends.Enqueue(bytesJSON); + } + + public async Task CloseWebsocket(WebSocketCloseStatus reason, string strReason) + { + UserWebSocketInstance websocketForUser = WebSocketManager.GetWebSocketForSession(this); + if (websocketForUser != null) + { + await websocketForUser.CloseAsync(reason, strReason); + } + + return websocketForUser; + } + + public async Task TickWebsocket(CancellationToken tickToken = default) + { + // Do we have a connection to send on? + UserWebSocketInstance websocketForUser = WebSocketManager.GetWebSocketForSession(this); + if (websocketForUser != null) + { + const int maxMessagesSendPerFrame = 50; + int messagesSent = 0; + // start dequeing and sending + while (!tickToken.IsCancellationRequested && messagesSent < maxMessagesSendPerFrame && m_lstPendingWebsocketSends.TryDequeue(out byte[] packetData)) + { + await websocketForUser.SendAsync(packetData, WebSocketMessageType.Text, tickToken); + ++messagesSent; + } + } + } + + // TODO_CACHE: Size limit this? + ConcurrentQueue m_lstPendingWebsocketSends = new ConcurrentQueue(); + + public bool NeedsCleanup() + { + const Int64 timeBeforeConsideredAbandoned = 30000; // 5 minutes + return Environment.TickCount64 - m_timeAbandoned >= timeBeforeConsideredAbandoned; + } + + private bool m_bSubscribedToRealtimeSocialupdates = false; + public void SetSubscribedToRealtimeSocialUpdates(bool bSubscribe) + { + m_bSubscribedToRealtimeSocialupdates = bSubscribe; + } + + public bool IsSubscribedToRealtimeSocialUpdates() + { + return m_bSubscribedToRealtimeSocialupdates; + } + + public string GetFullCountryName() + { + RegionInfo ri = new RegionInfo(m_strCountry); + return ri.EnglishName; + } + + public string GetFullContinentName() + { + switch (m_strContinent) + { + case "AF": return "Africa"; + case "AN": return "Antartica"; + case "AS": return "Asia"; + case "EU": return "Europe"; + case "NA": return "North America"; + case "OC": return "Oceania"; + case "SA": return "South America"; + case "T1": return "Tor"; + default: return "Unknown"; + } + } + + // Enough history to cover any realistic upload/outcome window; old entries just waste memory. + private const int MaxHistoricMatches = 50; + + public void RegisterHistoricMatchID(UInt64 matchID, int slotIndex, int army) + { + m_lstHistoricMatchIDs.Add(matchID); + m_lstHistoricMatchIDToSlotIndexMap[matchID] = slotIndex; + m_lstHistoricMatchIDToArmy[matchID] = army; + + // Trim oldest entries so the lists don't grow without bound over long sessions. + while (m_lstHistoricMatchIDs.Count > MaxHistoricMatches) + { + UInt64 oldest = m_lstHistoricMatchIDs[0]; + m_lstHistoricMatchIDs.Remove(oldest); + m_lstHistoricMatchIDToSlotIndexMap.TryRemove(oldest, out _); + m_lstHistoricMatchIDToArmy.TryRemove(oldest, out _); + } + } + + public bool WasPlayerInMatch(UInt64 matchID, out int slotIndexInLobby, out int army) + { + slotIndexInLobby = -1; + army = -1; + + bool bWasInMatch = m_lstHistoricMatchIDs.Contains(matchID); + + if (bWasInMatch) + { + slotIndexInLobby = m_lstHistoricMatchIDToSlotIndexMap[matchID]; + army = m_lstHistoricMatchIDToArmy[matchID]; + + } + + return bWasInMatch; + } + + public async Task UpdateSessionNetworkRoom(Int16 newRoomID) + { + Int16 oldRoom = networkRoomID; + networkRoomID = newRoomID; + + // update the room roster they left + if (oldRoom >= 0) // only if they werent in the dummy room before + { + await WebSocketManager.MarkRoomMemberListAsDirty(oldRoom); + } + + // send update to joiner + everyone in new room already + if (newRoomID >= 0) // only if they actually joined a room and weren't going to the dummy room + { + await WebSocketManager.MarkRoomMemberListAsDirty(newRoomID); + } + + // make the client force refresh list too + await WebSocketManager.SendNewOrDeletedLobbyToAllNetworkRoomMembers(this.networkRoomID); + } + + public void UpdateSessionLobbyID(Int64 newLobbyID) + { + if (m_sessionType == EUserSessionType.GameClient) + { + currentLobbyID = newLobbyID; + } + } + + // network room + public Int16 networkRoomID = -1; + + + // lobby id + public Int64 currentLobbyID = -1; + } - if (s3_url_ttl_minutes == null) - { - throw new Exception("s3_url_ttl_minutes missing in config"); - } + public class UserWebSocketInstance + { + // cached user data, useful + public EUserSessionType m_SessionType = EUserSessionType.None; + public Int64 m_UserID = -1; - TTL = (int)s3_url_ttl_minutes; - access_key = s3_access_key; - secret_key = s3_secret_key; - bucket_name = s3_bucket_name; - endpoint = s3_endpoint; - } + public Int64 m_lastPingTime = Environment.TickCount64; // last time we pinged this user, used to detect disconnects - public static async Task GetPresignedURL(EMetadataFileType fileType, EScreenshotType screenshotTypeIfScreenshot, UInt64 matchID, Int64 userID, int slotIndex) - { - GetS3Config(out int TTL, out string access_key, out string secret_key, out string bucket_name, out string client_endpoint); - TimeSpan expiresIn = TimeSpan.FromMinutes(TTL); - DateTime utcNow = DateTime.UtcNow; - int hour = utcNow.Hour; - int minute = utcNow.Minute; - string strPerMatchUserIDKey = Helpers.ComputeMD5Hash(String.Format("{0}_{1}", matchID, userID)); - string strFileName = null; + // TODO: Start using nullable for int values etc instead of doing 0 or -1 + public async Task SendPong() + { + OnPing(); + + // send pong back + WebSocketMessage_PONG outboundMsg = new WebSocketMessage_PONG(); + outboundMsg.msg_id = (int)EWebSocketMessageID.PONG; + byte[] bytesJSON = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(outboundMsg)); + await SendAsync(bytesJSON, WebSocketMessageType.Text); + } + + + private WebSocket? m_SockInternal = null; + + public UserWebSocketInstance(EUserSessionType sessionType, Int64 ownerID) : base() + { + m_SessionType = sessionType; + m_UserID = ownerID; + } + + public void AttachWebsocket(WebSocket sock) + { + m_SockInternal = sock; + } + + public void OnPing() + { + m_lastPingTime = Environment.TickCount64; + } + + public Int64 GetLastPingTime() + { + return m_lastPingTime; + } + + public Int64 GetTimeSinceLastPing() + { + return Environment.TickCount64 - m_lastPingTime; + } + + public async Task SendAsync(byte[] buffer, WebSocketMessageType messageType, CancellationToken externalToken = default) + { + if (m_SockInternal != null) + { + try + { + // should we chunked send? + /* + const int frameMax = 99999999; + if (buffer.Length > frameMax) + { + int bytresRemaining = buffer.Length; + int numFrames = (int)Math.Ceiling((float)buffer.Length / (float)frameMax); - string strContentType = String.Empty; + System.Diagnostics.Debug.WriteLine("[Websocket] sending {0} bytes in {1} chunks", bytresRemaining, numFrames); + for (int i = 0; i < numFrames; ++i) + { + int bytesToSend = Math.Min(bytresRemaining, frameMax); + bool bLastFrame = i == numFrames - 1; - if (fileType == EMetadataFileType.FILE_TYPE_SCREENSHOT) - { - strContentType = "image/jpeg"; - fileType = EMetadataFileType.FILE_TYPE_SCREENSHOT; - - string screenshotTypePrefix = "screenshot"; - if (screenshotTypeIfScreenshot == EScreenshotType.SCREENSHOT_TYPE_LOADSCREEN) - { - screenshotTypePrefix = "loadscreen"; - } - else if (screenshotTypeIfScreenshot == EScreenshotType.SCREENSHOT_TYPE_GAMEPLAY) - { - screenshotTypePrefix = "gameplay"; - } - else if (screenshotTypeIfScreenshot == EScreenshotType.SCREENSHOT_TYPE_SCORESCREEN) - { - screenshotTypePrefix = "scorescreen"; - } - - strFileName = String.Format("screenshot_{0}_{1}_{2}.jpg", screenshotTypePrefix, hour, minute); - } - else if (fileType == EMetadataFileType.FILE_TYPE_REPLAY) - { - strContentType = "application/octet-stream"; - fileType = EMetadataFileType.FILE_TYPE_REPLAY; + + ArraySegment arrSegment = new ArraySegment(buffer, i * frameMax, bytesToSend); + System.Diagnostics.Debug.WriteLine("[Websocket] send frame {0} with {1} bytes (last: {2})", i, bytesToSend, bLastFrame); + await m_SockInternal.SendAsync(arrSegment, messageType, bLastFrame, CancellationToken.None); - strFileName = String.Format("match_{0}_user_{1}_replay.rep", matchID, strPerMatchUserIDKey); - } + bytresRemaining -= bytesToSend; + } - if (strFileName == null) - { - return null; - } + } + else // just send whole + { + await m_SockInternal.SendAsync(buffer, messageType, true, CancellationToken.None); + } + */ - string objectKey = String.Format("match_{0}/user_{1}/{2}", matchID, strPerMatchUserIDKey, strFileName); + CancellationTokenSource cts = CancellationTokenSource.CreateLinkedTokenSource(externalToken); + try + { + cts.CancelAfter(TimeSpan.FromMilliseconds(500)); + await m_SockInternal.SendAsync(buffer, messageType, true, cts.Token); + } + finally + { + try + { + cts.Dispose(); + } + catch (ObjectDisposedException) + { + // The linked token may be disposed if the external token (parent CancellationTokenSource) + // fires its timeout while we're disposing this instance. This is safe to ignore. + } + } + } + catch + { + + } + } + } + + public async Task CloseAsync(WebSocketCloseStatus closeStatus, string? statusDescription) + { + if (m_SockInternal != null) + { + try + { + // dont wait forever, certain situations can cause that in ASP.NET + var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + await m_SockInternal.CloseAsync(closeStatus, statusDescription, cts.Token); + } + catch + { + + } + } + } + } - var request = new GetPreSignedUrlRequest - { - BucketName = bucket_name, - Key = objectKey, - Verb = HttpVerb.PUT, - Expires = DateTime.UtcNow.Add(expiresIn), - ContentType = strContentType - }; + public enum ESessionAccessType + { + Authenticate, // log in and out + Social, // friends lists + ServerListReadOnly, // can read lobby list and players etc, but cannot join + StatsReadOnly, // can read stats for any user, but not write anything + Gameplay, // Create lobbies, Anticheat, Middleware login, Matchmaking, match screenshots, replays, join lobby, etc + }; + public static 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(); + await 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); + } + } + } - // anytime we get an S3 uri, register the metadata - using var scope = ServiceLocator.Services.CreateScope(); - var factory = scope.ServiceProvider.GetRequiredService>(); - await using var db = await factory.CreateDbContextAsync(); + public enum EAccountType + { + Unknown = -1, + Steam = 0, + Discord = 1, + Ghost = 2, + DevAccount = 3 + } - await Database.MatchHistory.AttachMatchHistoryMetadata(db, matchID, slotIndex, strFileName, fileType); + public class PlayerStats + { + const int numGeneralsEntries = 15; - return await m_s3client.GetPreSignedURLAsync(request); - } - } + public PlayerStats(Int64 inUserID, int inEloRating, int inEloMatches) + { + userID = inUserID; + EloRating = inEloRating; + EloMatches = inEloMatches; + // init arrays, rest are init'ed below + for (int i = 0; i < numGeneralsEntries; ++i) + { + losses[i] = 0; + games[i] = 0; + duration[i] = 0; + wins[i] = 0; + unitsKilled[i] = 0; + unitsLost[i] = 0; + unitsBuilt[i] = 0; + buildingsKilled[i] = 0; + buildingsLost[i] = 0; + buildingsBuilt[i] = 0; + earnings[i] = 0; + techCaptured[i] = 0; + discons[i] = 0; + desyncs[i] = 0; + surrenders[i] = 0; + gamesOf2p[i] = 0; + gamesOf3p[i] = 0; + gamesOf4p[i] = 0; + gamesOf5p[i] = 0; + gamesOf6p[i] = 0; + gamesOf7p[i] = 0; + gamesOf8p[i] = 0; + customGames[i] = 0; + QMGames[i] = 0; + } + } + + public Int64 userID { get; set; } = -1; + public int EloRating { get; set; } = EloConfig.BaseRating; + public int EloMatches { get; set; } = 0; + + public int[] wins { get; set; } = new int[numGeneralsEntries] { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }; + public int[] losses { get; set; } = new int[numGeneralsEntries] { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }; + public int[] games { get; set; } = new int[numGeneralsEntries] { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }; + public int[] duration { get; set; } = new int[numGeneralsEntries] { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }; + public int[] unitsKilled { get; set; } = new int[numGeneralsEntries] { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }; + public int[] unitsLost { get; set; } = new int[numGeneralsEntries] { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }; + public int[] unitsBuilt { get; set; } = new int[numGeneralsEntries] { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }; + public int[] buildingsKilled { get; set; } = new int[numGeneralsEntries] { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }; + public int[] buildingsLost { get; set; } = new int[numGeneralsEntries] { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }; + public int[] buildingsBuilt { get; set; } = new int[numGeneralsEntries] { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }; + public int[] earnings { get; set; } = new int[numGeneralsEntries] { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }; + public int[] techCaptured { get; set; } = new int[numGeneralsEntries] { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }; + public int[] discons { get; set; } = new int[numGeneralsEntries] { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }; + public int[] desyncs { get; set; } = new int[numGeneralsEntries] { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }; + public int[] surrenders { get; set; } = new int[numGeneralsEntries] { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }; + public int[] gamesOf2p { get; set; } = new int[numGeneralsEntries] { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }; + public int[] gamesOf3p { get; set; } = new int[numGeneralsEntries] { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }; + public int[] gamesOf4p { get; set; } = new int[numGeneralsEntries] { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }; + public int[] gamesOf5p { get; set; } = new int[numGeneralsEntries] { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }; + public int[] gamesOf6p { get; set; } = new int[numGeneralsEntries] { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }; + public int[] gamesOf7p { get; set; } = new int[numGeneralsEntries] { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }; + public int[] gamesOf8p { get; set; } = new int[numGeneralsEntries] { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }; + public int[] customGames { get; set; } = new int[numGeneralsEntries] { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }; + public int[] QMGames { get; set; } = new int[numGeneralsEntries] { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }; + public int[] customGamesPerGeneral { get; set; } = new int[numGeneralsEntries] { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }; + public int[] quickMatchesPerGeneral { get; set; } = new int[numGeneralsEntries] { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }; + public int locale { get; set; } = 0; + public int gamesAsRandom { get; set; } = 0; + public string options { get; set; } = String.Empty; + public string systemSpec { get; set; } = String.Empty; + public float lastFPS { get; set; } = 0F; + public int lastGeneral { get; set; } = 0; + public int gamesInRowWithLastGeneral { get; set; } = 0; + public int challengeMedals { get; set; } = 0; + public int battleHonors { get; set; } = 0; + public int QMwinsInARow { get; set; } = 0; + public int maxQMwinsInARow { get; set; } = 0; + public int winsInARow { get; set; } = 0; + public int maxWinsInARow { get; set; } = 0; + public int lossesInARow { get; set; } = 0; + public int maxLossesInARow { get; set; } = 0; + public int disconsInARow { get; set; } = 0; + public int maxDisconsInARow { get; set; } = 0; + public int desyncsInARow { get; set; } = 0; + public int maxDesyncsInARow { get; set; } = 0; + public int builtParticleCannon { get; set; } = 0; + public int builtNuke { get; set; } = 0; + public int builtSCUD { get; set; } = 0; + public int lastLadderPort { get; set; } = 0; + public string lastLadderHost { get; set; } = String.Empty; + + public void ProcessFromDB(EStatIndex stat_id, int value) + { + int index = (int)stat_id % numGeneralsEntries; + switch (stat_id) + { + case >= EStatIndex.WINS_PER_GENERAL_0 and <= EStatIndex.WINS_PER_GENERAL_14: + wins[index] = value; + break; + case >= EStatIndex.LOSSES_PER_GENERAL_0 and <= EStatIndex.LOSSES_PER_GENERAL_14: + losses[index] = value; + break; + case >= EStatIndex.GAMES_PER_GENERAL_0 and <= EStatIndex.GAMES_PER_GENERAL_14: + games[index] = value; + break; + case >= EStatIndex.DURATION_PER_GENERAL_0 and <= EStatIndex.DURATION_PER_GENERAL_14: + duration[index] = value; + break; + case >= EStatIndex.UNITSKILLED_PER_GENERAL_0 and <= EStatIndex.UNITSKILLED_PER_GENERAL_14: + unitsKilled[index] = value; + break; + case >= EStatIndex.UNITSLOST_PER_GENERAL_0 and <= EStatIndex.UNITSLOST_PER_GENERAL_14: + unitsLost[index] = value; + break; + case >= EStatIndex.UNITSBUILT_PER_GENERAL_0 and <= EStatIndex.UNITSBUILT_PER_GENERAL_14: + unitsBuilt[index] = value; + break; + case >= EStatIndex.BUILDINGSKILLED_PER_GENERAL_0 and <= EStatIndex.BUILDINGSKILLED_PER_GENERAL_14: + buildingsKilled[index] = value; + break; + case >= EStatIndex.BUILDINGSLOST_PER_GENERAL_0 and <= EStatIndex.BUILDINGSLOST_PER_GENERAL_14: + buildingsLost[index] = value; + break; + case >= EStatIndex.BUILDINGSBUILT_PER_GENERAL_0 and <= EStatIndex.BUILDINGSBUILT_PER_GENERAL_14: + buildingsBuilt[index] = value; + break; + case >= EStatIndex.EARNINGS_PER_GENERAL_0 and <= EStatIndex.EARNINGS_PER_GENERAL_14: + earnings[index] = value; + break; + case >= EStatIndex.TECHCAPTURED_PER_GENERAL_0 and <= EStatIndex.TECHCAPTURED_PER_GENERAL_14: + techCaptured[index] = value; + break; + case >= EStatIndex.DISCONS_PER_GENERAL_0 and <= EStatIndex.DISCONS_PER_GENERAL_14: + discons[index] = value; + break; + case >= EStatIndex.DESYNCS_PER_GENERAL_0 and <= EStatIndex.DESYNCS_PER_GENERAL_14: + desyncs[index] = value; + break; + case >= EStatIndex.SURRENDERS_PER_GENERAL_0 and <= EStatIndex.SURRENDERS_PER_GENERAL_14: + surrenders[index] = value; + break; + case >= EStatIndex.GAMESOF2P_PER_GENERAL_0 and <= EStatIndex.GAMESOF2P_PER_GENERAL_14: + gamesOf2p[index] = value; + break; + case >= EStatIndex.GAMESOF3P_PER_GENERAL_0 and <= EStatIndex.GAMESOF3P_PER_GENERAL_14: + gamesOf3p[index] = value; + break; + case >= EStatIndex.GAMESOF4P_PER_GENERAL_0 and <= EStatIndex.GAMESOF4P_PER_GENERAL_14: + gamesOf4p[index] = value; + break; + case >= EStatIndex.GAMESOF5P_PER_GENERAL_0 and <= EStatIndex.GAMESOF5P_PER_GENERAL_14: + gamesOf5p[index] = value; + break; + case >= EStatIndex.GAMESOF6P_PER_GENERAL_0 and <= EStatIndex.GAMESOF6P_PER_GENERAL_14: + gamesOf6p[index] = value; + break; + case >= EStatIndex.GAMESOF7P_PER_GENERAL_0 and <= EStatIndex.GAMESOF7P_PER_GENERAL_14: + gamesOf7p[index] = value; + break; + case >= EStatIndex.GAMESOF8P_PER_GENERAL_0 and <= EStatIndex.GAMESOF8P_PER_GENERAL_14: + gamesOf8p[index] = value; + break; + case >= EStatIndex.CUSTOMGAMES_PER_GENERAL_0 and <= EStatIndex.CUSTOMGAMES_PER_GENERAL_14: + customGamesPerGeneral[index] = value; + break; + case >= EStatIndex.QUICKMATCHES_PER_GENERAL_0 and <= EStatIndex.QUICKMATCHES_PER_GENERAL_14: + quickMatchesPerGeneral[index] = value; + break; + case EStatIndex.LOCALE: + locale = value; + break; + case EStatIndex.GAMES_AS_RANDOM: + gamesAsRandom = value; + break; + case EStatIndex.LASTFPS: + lastFPS = value; + break; + case EStatIndex.LASTGENERAL: + lastGeneral = value; + break; + case EStatIndex.GAMESINROWWITHLASTGENERAL: + gamesInRowWithLastGeneral = value; + break; + case EStatIndex.CHALLENGEMEDALS: + challengeMedals = value; + break; + case EStatIndex.BATTLEHONORS: + battleHonors = value; + break; + case EStatIndex.QMWINSINAROW: + QMwinsInARow = value; + break; + case EStatIndex.MAXQMWINSINAROW: + maxQMwinsInARow = value; + break; + case EStatIndex.WINSINAROW: + winsInARow = value; + break; + case EStatIndex.MAXWINSINAROW: + maxWinsInARow = value; + break; + case EStatIndex.LOSSESINAROW: + lossesInARow = value; + break; + case EStatIndex.MAXLOSSESINAROW: + maxLossesInARow = value; + break; + case EStatIndex.DISCONSINAROW: + disconsInARow = value; + break; + case EStatIndex.MAXDISCONSINAROW: + maxDisconsInARow = value; + break; + case EStatIndex.DESYNCSINAROW: + desyncsInARow = value; + break; + case EStatIndex.MAXDESYNCSINAROW: + maxDesyncsInARow = value; + break; + case EStatIndex.BUILTPARTICLECANNON: + builtParticleCannon = value; + break; + case EStatIndex.BUILTNUKE: + builtNuke = value; + break; + case EStatIndex.BUILTSCUD: + builtSCUD = value; + break; + case EStatIndex.LASTLADDERPORT: + lastLadderPort = value; + break; + default: + throw new ArgumentOutOfRangeException(nameof(stat_id), $"Unhandled stat_id: {stat_id}"); + } + } + } - public static class TURNCredentialManager - { - private static ConcurrentDictionary g_DictTURNUsernames = new(); + public enum EStatIndex : UInt16 + { + WINS_PER_GENERAL_0, + WINS_PER_GENERAL_1, + WINS_PER_GENERAL_2, + WINS_PER_GENERAL_3, + WINS_PER_GENERAL_4, + WINS_PER_GENERAL_5, + WINS_PER_GENERAL_6, + WINS_PER_GENERAL_7, + WINS_PER_GENERAL_8, + WINS_PER_GENERAL_9, + WINS_PER_GENERAL_10, + WINS_PER_GENERAL_11, + WINS_PER_GENERAL_12, + WINS_PER_GENERAL_13, + WINS_PER_GENERAL_14, + + LOSSES_PER_GENERAL_0, + LOSSES_PER_GENERAL_1, + LOSSES_PER_GENERAL_2, + LOSSES_PER_GENERAL_3, + LOSSES_PER_GENERAL_4, + LOSSES_PER_GENERAL_5, + LOSSES_PER_GENERAL_6, + LOSSES_PER_GENERAL_7, + LOSSES_PER_GENERAL_8, + LOSSES_PER_GENERAL_9, + LOSSES_PER_GENERAL_10, + LOSSES_PER_GENERAL_11, + LOSSES_PER_GENERAL_12, + LOSSES_PER_GENERAL_13, + LOSSES_PER_GENERAL_14, + + GAMES_PER_GENERAL_0, + GAMES_PER_GENERAL_1, + GAMES_PER_GENERAL_2, + GAMES_PER_GENERAL_3, + GAMES_PER_GENERAL_4, + GAMES_PER_GENERAL_5, + GAMES_PER_GENERAL_6, + GAMES_PER_GENERAL_7, + GAMES_PER_GENERAL_8, + GAMES_PER_GENERAL_9, + GAMES_PER_GENERAL_10, + GAMES_PER_GENERAL_11, + GAMES_PER_GENERAL_12, + GAMES_PER_GENERAL_13, + GAMES_PER_GENERAL_14, + + DURATION_PER_GENERAL_0, + DURATION_PER_GENERAL_1, + DURATION_PER_GENERAL_2, + DURATION_PER_GENERAL_3, + DURATION_PER_GENERAL_4, + DURATION_PER_GENERAL_5, + DURATION_PER_GENERAL_6, + DURATION_PER_GENERAL_7, + DURATION_PER_GENERAL_8, + DURATION_PER_GENERAL_9, + DURATION_PER_GENERAL_10, + DURATION_PER_GENERAL_11, + DURATION_PER_GENERAL_12, + DURATION_PER_GENERAL_13, + DURATION_PER_GENERAL_14, + + UNITSKILLED_PER_GENERAL_0, + UNITSKILLED_PER_GENERAL_1, + UNITSKILLED_PER_GENERAL_2, + UNITSKILLED_PER_GENERAL_3, + UNITSKILLED_PER_GENERAL_4, + UNITSKILLED_PER_GENERAL_5, + UNITSKILLED_PER_GENERAL_6, + UNITSKILLED_PER_GENERAL_7, + UNITSKILLED_PER_GENERAL_8, + UNITSKILLED_PER_GENERAL_9, + UNITSKILLED_PER_GENERAL_10, + UNITSKILLED_PER_GENERAL_11, + UNITSKILLED_PER_GENERAL_12, + UNITSKILLED_PER_GENERAL_13, + UNITSKILLED_PER_GENERAL_14, + + UNITSLOST_PER_GENERAL_0, + UNITSLOST_PER_GENERAL_1, + UNITSLOST_PER_GENERAL_2, + UNITSLOST_PER_GENERAL_3, + UNITSLOST_PER_GENERAL_4, + UNITSLOST_PER_GENERAL_5, + UNITSLOST_PER_GENERAL_6, + UNITSLOST_PER_GENERAL_7, + UNITSLOST_PER_GENERAL_8, + UNITSLOST_PER_GENERAL_9, + UNITSLOST_PER_GENERAL_10, + UNITSLOST_PER_GENERAL_11, + UNITSLOST_PER_GENERAL_12, + UNITSLOST_PER_GENERAL_13, + UNITSLOST_PER_GENERAL_14, + + UNITSBUILT_PER_GENERAL_0, + UNITSBUILT_PER_GENERAL_1, + UNITSBUILT_PER_GENERAL_2, + UNITSBUILT_PER_GENERAL_3, + UNITSBUILT_PER_GENERAL_4, + UNITSBUILT_PER_GENERAL_5, + UNITSBUILT_PER_GENERAL_6, + UNITSBUILT_PER_GENERAL_7, + UNITSBUILT_PER_GENERAL_8, + UNITSBUILT_PER_GENERAL_9, + UNITSBUILT_PER_GENERAL_10, + UNITSBUILT_PER_GENERAL_11, + UNITSBUILT_PER_GENERAL_12, + UNITSBUILT_PER_GENERAL_13, + UNITSBUILT_PER_GENERAL_14, + + BUILDINGSKILLED_PER_GENERAL_0, + BUILDINGSKILLED_PER_GENERAL_1, + BUILDINGSKILLED_PER_GENERAL_2, + BUILDINGSKILLED_PER_GENERAL_3, + BUILDINGSKILLED_PER_GENERAL_4, + BUILDINGSKILLED_PER_GENERAL_5, + BUILDINGSKILLED_PER_GENERAL_6, + BUILDINGSKILLED_PER_GENERAL_7, + BUILDINGSKILLED_PER_GENERAL_8, + BUILDINGSKILLED_PER_GENERAL_9, + BUILDINGSKILLED_PER_GENERAL_10, + BUILDINGSKILLED_PER_GENERAL_11, + BUILDINGSKILLED_PER_GENERAL_12, + BUILDINGSKILLED_PER_GENERAL_13, + BUILDINGSKILLED_PER_GENERAL_14, + + BUILDINGSLOST_PER_GENERAL_0, + BUILDINGSLOST_PER_GENERAL_1, + BUILDINGSLOST_PER_GENERAL_2, + BUILDINGSLOST_PER_GENERAL_3, + BUILDINGSLOST_PER_GENERAL_4, + BUILDINGSLOST_PER_GENERAL_5, + BUILDINGSLOST_PER_GENERAL_6, + BUILDINGSLOST_PER_GENERAL_7, + BUILDINGSLOST_PER_GENERAL_8, + BUILDINGSLOST_PER_GENERAL_9, + BUILDINGSLOST_PER_GENERAL_10, + BUILDINGSLOST_PER_GENERAL_11, + BUILDINGSLOST_PER_GENERAL_12, + BUILDINGSLOST_PER_GENERAL_13, + BUILDINGSLOST_PER_GENERAL_14, + + BUILDINGSBUILT_PER_GENERAL_0, + BUILDINGSBUILT_PER_GENERAL_1, + BUILDINGSBUILT_PER_GENERAL_2, + BUILDINGSBUILT_PER_GENERAL_3, + BUILDINGSBUILT_PER_GENERAL_4, + BUILDINGSBUILT_PER_GENERAL_5, + BUILDINGSBUILT_PER_GENERAL_6, + BUILDINGSBUILT_PER_GENERAL_7, + BUILDINGSBUILT_PER_GENERAL_8, + BUILDINGSBUILT_PER_GENERAL_9, + BUILDINGSBUILT_PER_GENERAL_10, + BUILDINGSBUILT_PER_GENERAL_11, + BUILDINGSBUILT_PER_GENERAL_12, + BUILDINGSBUILT_PER_GENERAL_13, + BUILDINGSBUILT_PER_GENERAL_14, + + EARNINGS_PER_GENERAL_0, + EARNINGS_PER_GENERAL_1, + EARNINGS_PER_GENERAL_2, + EARNINGS_PER_GENERAL_3, + EARNINGS_PER_GENERAL_4, + EARNINGS_PER_GENERAL_5, + EARNINGS_PER_GENERAL_6, + EARNINGS_PER_GENERAL_7, + EARNINGS_PER_GENERAL_8, + EARNINGS_PER_GENERAL_9, + EARNINGS_PER_GENERAL_10, + EARNINGS_PER_GENERAL_11, + EARNINGS_PER_GENERAL_12, + EARNINGS_PER_GENERAL_13, + EARNINGS_PER_GENERAL_14, + + TECHCAPTURED_PER_GENERAL_0, + TECHCAPTURED_PER_GENERAL_1, + TECHCAPTURED_PER_GENERAL_2, + TECHCAPTURED_PER_GENERAL_3, + TECHCAPTURED_PER_GENERAL_4, + TECHCAPTURED_PER_GENERAL_5, + TECHCAPTURED_PER_GENERAL_6, + TECHCAPTURED_PER_GENERAL_7, + TECHCAPTURED_PER_GENERAL_8, + TECHCAPTURED_PER_GENERAL_9, + TECHCAPTURED_PER_GENERAL_10, + TECHCAPTURED_PER_GENERAL_11, + TECHCAPTURED_PER_GENERAL_12, + TECHCAPTURED_PER_GENERAL_13, + TECHCAPTURED_PER_GENERAL_14, + + DISCONS_PER_GENERAL_0, + DISCONS_PER_GENERAL_1, + DISCONS_PER_GENERAL_2, + DISCONS_PER_GENERAL_3, + DISCONS_PER_GENERAL_4, + DISCONS_PER_GENERAL_5, + DISCONS_PER_GENERAL_6, + DISCONS_PER_GENERAL_7, + DISCONS_PER_GENERAL_8, + DISCONS_PER_GENERAL_9, + DISCONS_PER_GENERAL_10, + DISCONS_PER_GENERAL_11, + DISCONS_PER_GENERAL_12, + DISCONS_PER_GENERAL_13, + DISCONS_PER_GENERAL_14, + + DESYNCS_PER_GENERAL_0, + DESYNCS_PER_GENERAL_1, + DESYNCS_PER_GENERAL_2, + DESYNCS_PER_GENERAL_3, + DESYNCS_PER_GENERAL_4, + DESYNCS_PER_GENERAL_5, + DESYNCS_PER_GENERAL_6, + DESYNCS_PER_GENERAL_7, + DESYNCS_PER_GENERAL_8, + DESYNCS_PER_GENERAL_9, + DESYNCS_PER_GENERAL_10, + DESYNCS_PER_GENERAL_11, + DESYNCS_PER_GENERAL_12, + DESYNCS_PER_GENERAL_13, + DESYNCS_PER_GENERAL_14, + + SURRENDERS_PER_GENERAL_0, + SURRENDERS_PER_GENERAL_1, + SURRENDERS_PER_GENERAL_2, + SURRENDERS_PER_GENERAL_3, + SURRENDERS_PER_GENERAL_4, + SURRENDERS_PER_GENERAL_5, + SURRENDERS_PER_GENERAL_6, + SURRENDERS_PER_GENERAL_7, + SURRENDERS_PER_GENERAL_8, + SURRENDERS_PER_GENERAL_9, + SURRENDERS_PER_GENERAL_10, + SURRENDERS_PER_GENERAL_11, + SURRENDERS_PER_GENERAL_12, + SURRENDERS_PER_GENERAL_13, + SURRENDERS_PER_GENERAL_14, + + GAMESOF2P_PER_GENERAL_0, + GAMESOF2P_PER_GENERAL_1, + GAMESOF2P_PER_GENERAL_2, + GAMESOF2P_PER_GENERAL_3, + GAMESOF2P_PER_GENERAL_4, + GAMESOF2P_PER_GENERAL_5, + GAMESOF2P_PER_GENERAL_6, + GAMESOF2P_PER_GENERAL_7, + GAMESOF2P_PER_GENERAL_8, + GAMESOF2P_PER_GENERAL_9, + GAMESOF2P_PER_GENERAL_10, + GAMESOF2P_PER_GENERAL_11, + GAMESOF2P_PER_GENERAL_12, + GAMESOF2P_PER_GENERAL_13, + GAMESOF2P_PER_GENERAL_14, + + GAMESOF3P_PER_GENERAL_0, + GAMESOF3P_PER_GENERAL_1, + GAMESOF3P_PER_GENERAL_2, + GAMESOF3P_PER_GENERAL_3, + GAMESOF3P_PER_GENERAL_4, + GAMESOF3P_PER_GENERAL_5, + GAMESOF3P_PER_GENERAL_6, + GAMESOF3P_PER_GENERAL_7, + GAMESOF3P_PER_GENERAL_8, + GAMESOF3P_PER_GENERAL_9, + GAMESOF3P_PER_GENERAL_10, + GAMESOF3P_PER_GENERAL_11, + GAMESOF3P_PER_GENERAL_12, + GAMESOF3P_PER_GENERAL_13, + GAMESOF3P_PER_GENERAL_14, + + GAMESOF4P_PER_GENERAL_0, + GAMESOF4P_PER_GENERAL_1, + GAMESOF4P_PER_GENERAL_2, + GAMESOF4P_PER_GENERAL_3, + GAMESOF4P_PER_GENERAL_4, + GAMESOF4P_PER_GENERAL_5, + GAMESOF4P_PER_GENERAL_6, + GAMESOF4P_PER_GENERAL_7, + GAMESOF4P_PER_GENERAL_8, + GAMESOF4P_PER_GENERAL_9, + GAMESOF4P_PER_GENERAL_10, + GAMESOF4P_PER_GENERAL_11, + GAMESOF4P_PER_GENERAL_12, + GAMESOF4P_PER_GENERAL_13, + GAMESOF4P_PER_GENERAL_14, + + GAMESOF5P_PER_GENERAL_0, + GAMESOF5P_PER_GENERAL_1, + GAMESOF5P_PER_GENERAL_2, + GAMESOF5P_PER_GENERAL_3, + GAMESOF5P_PER_GENERAL_4, + GAMESOF5P_PER_GENERAL_5, + GAMESOF5P_PER_GENERAL_6, + GAMESOF5P_PER_GENERAL_7, + GAMESOF5P_PER_GENERAL_8, + GAMESOF5P_PER_GENERAL_9, + GAMESOF5P_PER_GENERAL_10, + GAMESOF5P_PER_GENERAL_11, + GAMESOF5P_PER_GENERAL_12, + GAMESOF5P_PER_GENERAL_13, + GAMESOF5P_PER_GENERAL_14, + + GAMESOF6P_PER_GENERAL_0, + GAMESOF6P_PER_GENERAL_1, + GAMESOF6P_PER_GENERAL_2, + GAMESOF6P_PER_GENERAL_3, + GAMESOF6P_PER_GENERAL_4, + GAMESOF6P_PER_GENERAL_5, + GAMESOF6P_PER_GENERAL_6, + GAMESOF6P_PER_GENERAL_7, + GAMESOF6P_PER_GENERAL_8, + GAMESOF6P_PER_GENERAL_9, + GAMESOF6P_PER_GENERAL_10, + GAMESOF6P_PER_GENERAL_11, + GAMESOF6P_PER_GENERAL_12, + GAMESOF6P_PER_GENERAL_13, + GAMESOF6P_PER_GENERAL_14, + + GAMESOF7P_PER_GENERAL_0, + GAMESOF7P_PER_GENERAL_1, + GAMESOF7P_PER_GENERAL_2, + GAMESOF7P_PER_GENERAL_3, + GAMESOF7P_PER_GENERAL_4, + GAMESOF7P_PER_GENERAL_5, + GAMESOF7P_PER_GENERAL_6, + GAMESOF7P_PER_GENERAL_7, + GAMESOF7P_PER_GENERAL_8, + GAMESOF7P_PER_GENERAL_9, + GAMESOF7P_PER_GENERAL_10, + GAMESOF7P_PER_GENERAL_11, + GAMESOF7P_PER_GENERAL_12, + GAMESOF7P_PER_GENERAL_13, + GAMESOF7P_PER_GENERAL_14, + + GAMESOF8P_PER_GENERAL_0, + GAMESOF8P_PER_GENERAL_1, + GAMESOF8P_PER_GENERAL_2, + GAMESOF8P_PER_GENERAL_3, + GAMESOF8P_PER_GENERAL_4, + GAMESOF8P_PER_GENERAL_5, + GAMESOF8P_PER_GENERAL_6, + GAMESOF8P_PER_GENERAL_7, + GAMESOF8P_PER_GENERAL_8, + GAMESOF8P_PER_GENERAL_9, + GAMESOF8P_PER_GENERAL_10, + GAMESOF8P_PER_GENERAL_11, + GAMESOF8P_PER_GENERAL_12, + GAMESOF8P_PER_GENERAL_13, + GAMESOF8P_PER_GENERAL_14, + + CUSTOMGAMES_PER_GENERAL_0, + CUSTOMGAMES_PER_GENERAL_1, + CUSTOMGAMES_PER_GENERAL_2, + CUSTOMGAMES_PER_GENERAL_3, + CUSTOMGAMES_PER_GENERAL_4, + CUSTOMGAMES_PER_GENERAL_5, + CUSTOMGAMES_PER_GENERAL_6, + CUSTOMGAMES_PER_GENERAL_7, + CUSTOMGAMES_PER_GENERAL_8, + CUSTOMGAMES_PER_GENERAL_9, + CUSTOMGAMES_PER_GENERAL_10, + CUSTOMGAMES_PER_GENERAL_11, + CUSTOMGAMES_PER_GENERAL_12, + CUSTOMGAMES_PER_GENERAL_13, + CUSTOMGAMES_PER_GENERAL_14, + + QUICKMATCHES_PER_GENERAL_0, + QUICKMATCHES_PER_GENERAL_1, + QUICKMATCHES_PER_GENERAL_2, + QUICKMATCHES_PER_GENERAL_3, + QUICKMATCHES_PER_GENERAL_4, + QUICKMATCHES_PER_GENERAL_5, + QUICKMATCHES_PER_GENERAL_6, + QUICKMATCHES_PER_GENERAL_7, + QUICKMATCHES_PER_GENERAL_8, + QUICKMATCHES_PER_GENERAL_9, + QUICKMATCHES_PER_GENERAL_10, + QUICKMATCHES_PER_GENERAL_11, + QUICKMATCHES_PER_GENERAL_12, + QUICKMATCHES_PER_GENERAL_13, + QUICKMATCHES_PER_GENERAL_14, + + LOCALE, + GAMES_AS_RANDOM, + OPTIONS, + SYSTEM_SPEC, + LASTFPS, + LASTGENERAL, + GAMESINROWWITHLASTGENERAL, + CHALLENGEMEDALS, + BATTLEHONORS, + QMWINSINAROW, + MAXQMWINSINAROW, + WINSINAROW, + MAXWINSINAROW, + LOSSESINAROW, + MAXLOSSESINAROW, + DISCONSINAROW, + + MAXDISCONSINAROW, + DESYNCSINAROW, + MAXDESYNCSINAROW, + BUILTPARTICLECANNON, + BUILTNUKE, + BUILTSCUD, + LASTLADDERPORT, + LASTLADDERHOST + } - private static void GetTURNConfig(out int TTL, out string token, out string key, out bool bShouldInvalidateTokensAutomatically) - { - TTL = -1; - token = String.Empty; - key = String.Empty; + public class TURNCredentialContainer + { + public TURNCredentialContainer(string strUsername, string strToken) + { + m_strToken = strToken; + m_strUsername = strUsername; + } + + public string m_strUsername; + public string m_strToken; + } - if (Program.g_Config == null) - { - throw new Exception("Config not loaded"); - } + public class iceEntry + { + public List? urls { get; set; } = null; + public string? username { get; set; } = null; + public string? credential { get; set; } = null; + } - IConfigurationSection? turnSettings = Program.g_Config.GetSection("TURN"); + public class TURNResponse + { + public List? iceServers { get; set; } + } - if (turnSettings == null) - { - throw new Exception("TURN section missing in config"); - } + public static class S3CredentialManager + { + private static AmazonS3Client m_s3client = null; + + public static void Initialize() + { + GetS3Config(out int TTL, out string access_key, out string secret_key, out string bucket_name, out string client_endpoint); + + var config = new AmazonS3Config + { + ServiceURL = client_endpoint, + ForcePathStyle = true + }; + + m_s3client = new AmazonS3Client(access_key, secret_key, config); + } + + private static void GetS3Config(out int TTL, out string access_key, out string secret_key, out string bucket_name, out string endpoint) + { + TTL = -1; + access_key = String.Empty; + secret_key = String.Empty; + bucket_name = String.Empty; + endpoint = String.Empty; + + if (Program.g_Config == null) + { + throw new Exception("Config not loaded"); + } + + IConfigurationSection? turnSettings = Program.g_Config.GetSection("MatchData"); + + if (turnSettings == null) + { + throw new Exception("MatchData section missing in config"); + } + + string? s3_access_key = turnSettings.GetValue("s3_access_key"); + string? s3_secret_key = turnSettings.GetValue("s3_secret_key"); + string? s3_bucket_name = turnSettings.GetValue("s3_bucket_name"); + string? s3_endpoint = turnSettings.GetValue("s3_endpoint"); + int? s3_url_ttl_minutes = turnSettings.GetValue("s3_url_ttl_minutes"); + + if (s3_access_key == null) + { + throw new Exception("s3_access_key missing in config"); + } + + if (s3_secret_key == null) + { + throw new Exception("s3_secret_key missing in config"); + } + + if (s3_bucket_name == null) + { + throw new Exception("s3_bucket_name missing in config"); + } + + if (s3_endpoint == null) + { + throw new Exception("s3_endpoint missing in config"); + } + + if (s3_url_ttl_minutes == null) + { + throw new Exception("s3_url_ttl_minutes missing in config"); + } + + TTL = (int)s3_url_ttl_minutes; + access_key = s3_access_key; + secret_key = s3_secret_key; + bucket_name = s3_bucket_name; + endpoint = s3_endpoint; + } + + public static async Task GetPresignedURL(EMetadataFileType fileType, EScreenshotType screenshotTypeIfScreenshot, UInt64 matchID, Int64 userID, int slotIndex) + { + GetS3Config(out int TTL, out string access_key, out string secret_key, out string bucket_name, out string client_endpoint); + TimeSpan expiresIn = TimeSpan.FromMinutes(TTL); + + DateTime utcNow = DateTime.UtcNow; + int hour = utcNow.Hour; + int minute = utcNow.Minute; + + string strPerMatchUserIDKey = Helpers.ComputeMD5Hash(String.Format("{0}_{1}", matchID, userID)); + string strFileName = null; + + string strContentType = String.Empty; + + + if (fileType == EMetadataFileType.FILE_TYPE_SCREENSHOT) + { + strContentType = "image/jpeg"; + fileType = EMetadataFileType.FILE_TYPE_SCREENSHOT; + + string screenshotTypePrefix = "screenshot"; + if (screenshotTypeIfScreenshot == EScreenshotType.SCREENSHOT_TYPE_LOADSCREEN) + { + screenshotTypePrefix = "loadscreen"; + } + else if (screenshotTypeIfScreenshot == EScreenshotType.SCREENSHOT_TYPE_GAMEPLAY) + { + screenshotTypePrefix = "gameplay"; + } + else if (screenshotTypeIfScreenshot == EScreenshotType.SCREENSHOT_TYPE_SCORESCREEN) + { + screenshotTypePrefix = "scorescreen"; + } + + strFileName = String.Format("screenshot_{0}_{1}_{2}.jpg", screenshotTypePrefix, hour, minute); + } + else if (fileType == EMetadataFileType.FILE_TYPE_REPLAY) + { + strContentType = "application/octet-stream"; + fileType = EMetadataFileType.FILE_TYPE_REPLAY; + + strFileName = String.Format("match_{0}_user_{1}_replay.rep", matchID, strPerMatchUserIDKey); + } + + if (strFileName == null) + { + return null; + } + + string objectKey = String.Format("match_{0}/user_{1}/{2}", matchID, strPerMatchUserIDKey, strFileName); + + var request = new GetPreSignedUrlRequest + { + BucketName = bucket_name, + Key = objectKey, + Verb = HttpVerb.PUT, + Expires = DateTime.UtcNow.Add(expiresIn), + ContentType = strContentType + }; + + // anytime we get an S3 uri, register the metadata + using var scope = ServiceLocator.Services.CreateScope(); + var factory = scope.ServiceProvider.GetRequiredService>(); + await using var db = await factory.CreateDbContextAsync(); + + await Database.MatchHistory.AttachMatchHistoryMetadata(db, matchID, slotIndex, strFileName, fileType); + + return await m_s3client.GetPreSignedURLAsync(request); + } + } - string? turn_key = turnSettings.GetValue("key"); - string? turn_token = turnSettings.GetValue("token"); - int? token_ttl = turnSettings.GetValue("token_ttl"); - bool? automatic_token_invalidate = turnSettings.GetValue("automatic_token_invalidate"); - if (turn_key == null) - { - throw new Exception("turn_key missing in config"); - } + public static class TURNCredentialManager + { + private static ConcurrentDictionary g_DictTURNUsernames = new(); + + private static void GetTURNConfig(out int TTL, out string token, out string key, out bool bShouldInvalidateTokensAutomatically) + { + TTL = -1; + token = String.Empty; + key = String.Empty; + + if (Program.g_Config == null) + { + throw new Exception("Config not loaded"); + } + + IConfigurationSection? turnSettings = Program.g_Config.GetSection("TURN"); + + if (turnSettings == null) + { + throw new Exception("TURN section missing in config"); + } + + string? turn_key = turnSettings.GetValue("key"); + string? turn_token = turnSettings.GetValue("token"); + int? token_ttl = turnSettings.GetValue("token_ttl"); + bool? automatic_token_invalidate = turnSettings.GetValue("automatic_token_invalidate"); + + if (turn_key == null) + { + throw new Exception("turn_key missing in config"); + } + + if (turn_token == null) + { + throw new Exception("turn_token missing in config"); + } + + if (token_ttl == null) + { + throw new Exception("token_ttl missing in config"); + } + + if (automatic_token_invalidate == null) + { + throw new Exception("automatic_token_invalidate missing in config"); + } + + TTL = (int)token_ttl; + token = turn_token; + key = turn_key; + bShouldInvalidateTokensAutomatically = (bool)automatic_token_invalidate; + } + + public static async Task CreateCredentialsForUser(Int64 userID) + { +#if DEBUG + TURNCredentialContainer fakeCreds = new("fake", "fake"); + await Task.Delay(1); + return fakeCreds; +#endif + GetTURNConfig(out int TurnTTL, out string TurnToken, out string TurnKey, out bool bShouldInvalidateTokensAutomatically); - if (turn_token == null) - { - throw new Exception("turn_token missing in config"); - } + // we should only have 1 turn credential at a time... clean it up + if (g_DictTURNUsernames.ContainsKey(userID)) + { + await DeleteCredentialsForUser(userID); + } + + // create new credential + Dictionary dictReqData = new(); + dictReqData.Add("ttl", TurnTTL); // 4 hours + dictReqData.Add("go_user_id", userID); // go user id + var jsonContent = JsonSerializer.Serialize(dictReqData); + using var requestContent = new StringContent(jsonContent, System.Text.Encoding.UTF8, "application/json"); + + using (HttpClient client = new HttpClient(new SocketsHttpHandler() + { + ConnectCallback = async (context, cancellationToken) => + { + // Use DNS to look up the IP addresses of the target host: + // - IP v4: AddressFamily.InterNetwork + // - IP v6: AddressFamily.InterNetworkV6 + // - IP v4 or IP v6: AddressFamily.Unspecified + // note: this method throws a SocketException when there is no IP address for the host + var entry = await Dns.GetHostEntryAsync(context.DnsEndPoint.Host, AddressFamily.InterNetwork, cancellationToken); + + // Open the connection to the target host/port + var socket = new Socket(SocketType.Stream, ProtocolType.Tcp); + + // Turn off Nagle's algorithm since it degrades performance in most HttpClient scenarios. + socket.NoDelay = true; + + try + { + await socket.ConnectAsync(entry.AddressList, context.DnsEndPoint.Port, cancellationToken); + + // If you want to choose a specific IP address to connect to the server + // await socket.ConnectAsync( + // entry.AddressList[Random.Shared.Next(0, entry.AddressList.Length)], + // context.DnsEndPoint.Port, cancellationToken); + + // Return the NetworkStream to the caller + return new NetworkStream(socket, ownsSocket: true); + } + catch + { + socket.Dispose(); + throw; + } + } + })) + { + client.Timeout = TimeSpan.FromSeconds(10); + client.DefaultRequestHeaders.Add("Authorization", String.Format("Bearer {0}", TurnToken)); + client.DefaultRequestHeaders.Add("Accept", "application/json"); + //client.DefaultRequestHeaders.Add("Content-Type", "application/json"); + try + { + Console.WriteLine("Start req turn credentials at {0}", Environment.TickCount); + string strURI = String.Format("https://rtc.live.cloudflare.com/v1/turn/keys/{0}/credentials/generate-ice-servers", TurnKey); + HttpResponseMessage response = await client.PostAsync(strURI, requestContent); + + if (response.IsSuccessStatusCode) + { + Console.WriteLine("Finish req turn credentials at {0}", Environment.TickCount); + string responseBody = await response.Content.ReadAsStringAsync(); + TURNResponse? resp = JsonSerializer.Deserialize(responseBody); + + try + { + if (resp != null && resp.iceServers != null) + { + foreach (iceEntry? entry in resp.iceServers) + { + if (!string.IsNullOrEmpty(entry.username) && !string.IsNullOrEmpty(entry.credential)) + { + TURNCredentialContainer creds = new(entry.username, entry.credential); + g_DictTURNUsernames[userID] = entry.username; + return creds; + } + } + } + + return null; + } + catch + { + + } + + return null; + } + } + catch + { + + } + } + + return null; + } + + public static async Task DeleteCredentialsForUser(Int64 userID) + { +#if DEBUG + await Task.Delay(1); + return; +#endif - if (token_ttl == null) - { - throw new Exception("token_ttl missing in config"); - } + GetTURNConfig(out int TurnTTL, out string TurnToken, out string TurnKey, out bool bShouldInvalidateTokensAutomatically); - if (automatic_token_invalidate == null) - { - throw new Exception("automatic_token_invalidate missing in config"); - } + if (!bShouldInvalidateTokensAutomatically) + { + return; + } + + if (g_DictTURNUsernames.ContainsKey(userID)) + { + string strTURNUsername = g_DictTURNUsernames[userID]; + + g_DictTURNUsernames.Remove(userID, out string? strUsername); + + // revoke credential + Dictionary dictReqData = new(); + var jsonContent = JsonSerializer.Serialize(dictReqData); + using var requestContent = new StringContent(jsonContent, System.Text.Encoding.UTF8, "application/json"); + + using (HttpClient client = new HttpClient(new SocketsHttpHandler() + { + ConnectCallback = async (context, cancellationToken) => + { + // Use DNS to look up the IP addresses of the target host: + // - IP v4: AddressFamily.InterNetwork + // - IP v6: AddressFamily.InterNetworkV6 + // - IP v4 or IP v6: AddressFamily.Unspecified + // note: this method throws a SocketException when there is no IP address for the host + var entry = await Dns.GetHostEntryAsync(context.DnsEndPoint.Host, AddressFamily.InterNetwork, cancellationToken); + + // Open the connection to the target host/port + var socket = new Socket(SocketType.Stream, ProtocolType.Tcp); + + // Turn off Nagle's algorithm since it degrades performance in most HttpClient scenarios. + socket.NoDelay = true; + + try + { + await socket.ConnectAsync(entry.AddressList, context.DnsEndPoint.Port, cancellationToken); + + // If you want to choose a specific IP address to connect to the server + // await socket.ConnectAsync( + // entry.AddressList[Random.Shared.Next(0, entry.AddressList.Length)], + // context.DnsEndPoint.Port, cancellationToken); + + // Return the NetworkStream to the caller + return new NetworkStream(socket, ownsSocket: true); + } + catch + { + socket.Dispose(); + throw; + } + } + })) + { + client.Timeout = TimeSpan.FromSeconds(10); + client.DefaultRequestHeaders.Add("Authorization", String.Format("Bearer {0}", TurnToken)); + client.DefaultRequestHeaders.Add("Accept", "application/json"); + try + { + string strURI = String.Format("https://rtc.live.cloudflare.com/v1/turn/keys/{0}/credentials/{1}/revoke", TurnKey, strTURNUsername); + HttpResponseMessage response = await client.PostAsync(strURI, requestContent); + } + catch + { + + } + } + } + } + } - TTL = (int)token_ttl; - token = turn_token; - key = turn_key; - bShouldInvalidateTokensAutomatically = (bool)automatic_token_invalidate; - } + public enum EPlayerType + { + SLOT_OPEN, + SLOT_CLOSED, + SLOT_EASY_AI, + SLOT_MED_AI, + SLOT_BRUTAL_AI, + SLOT_PLAYER + } - public static async Task CreateCredentialsForUser(Int64 userID) - { -#if DEBUG - TURNCredentialContainer fakeCreds = new("fake", "fake"); - await Task.Delay(1); - return fakeCreds; -#endif - GetTURNConfig(out int TurnTTL, out string TurnToken, out string TurnKey, out bool bShouldInvalidateTokensAutomatically); + public enum EWebSocketMessageID + { + UNKNOWN = -1, + NETWORK_ROOM_CHAT_FROM_CLIENT = 1, + NETWORK_ROOM_CHAT_FROM_SERVER = 2, + NETWORK_ROOM_CHANGE_ROOM = 3, + NETWORK_ROOM_MEMBER_LIST_UPDATE = 4, // TODO: This could be more optimal, send a diff instead of full list? + NETWORK_ROOM_MARK_READY = 5, + LOBBY_CURRENT_LOBBY_UPDATE = 6, + NETWORK_ROOM_LOBBY_LIST_UPDATE = 7, + UNUSED_PLACEHOLDER = 8, // this was relay upgrade, was removed. We can re-use it later, but service needs this placeholder + PLAYER_NAME_CHANGE = 9, + LOBBY_ROOM_CHAT_FROM_CLIENT = 10, + LOBBY_CHAT_FROM_SERVER = 11, + NETWORK_SIGNAL = 12, + START_GAME = 13, + PING = 14, + PONG = 15, + PROBE = 16, + NETWORK_CONNECTION_START_SIGNALLING = 17, + NETWORK_CONNECTION_DISCONNECT_PLAYER = 18, + NETWORK_CONNECTION_CLIENT_REQUEST_SIGNALLING = 19, + MATCHMAKING_ACTION_JOIN_PREARRANGED_LOBBY = 20, + MATCHMAKING_ACTION_START_GAME = 21, + MATCHMAKING_MESSAGE = 22, + START_GAME_COUNTDOWN_STARTED = 23, + LOBBY_REMOVE_PASSWORD = 24, + LOBBY_CHANGE_PASSWORD = 25, + FULL_MESH_CONNECTIVITY_CHECK_HOST_REQUESTS_BEGIN = 26, + FULL_MESH_CONNECTIVITY_CHECK_RESPONSE = 27, + FULL_MESH_CONNECTIVITY_CHECK_RESPONSE_COMPLETE_TO_HOST = 28, + SOCIAL_NEW_FRIEND_REQUEST = 29, + SOCIAL_FRIEND_CHAT_MESSAGE_CLIENT_TO_SERVER = 30, + SOCIAL_FRIEND_CHAT_MESSAGE_SERVER_TO_CLIENT = 31, + SOCIAL_FRIEND_ONLINE_STATUS_CHANGED = 32, + SOCIAL_SUBSCRIBE_REALTIME_UPDATES = 33, + SOCIAL_UNSUBSCRIBE_REALTIME_UPDATES = 34, + SOCIAL_FRIENDS_OVERALL_STATUS_UPDATE = 35, + SOCIAL_FRIEND_FRIEND_REQUEST_ACCEPTED_BY_TARGET = 36, + SOCIAL_FRIENDS_LIST_DIRTY = 37, + SOCIAL_CANT_ADD_FRIEND_LIST_FULL = 38, + PROBE_RESP = 39, + AC_REGISTER_PLAYER = 40, + AC_DEREGISTER_PLAYER = 41 + }; - // we should only have 1 turn credential at a time... clean it up - if (g_DictTURNUsernames.ContainsKey(userID)) - { - await DeleteCredentialsForUser(userID); - } + public static class UserPresence + { + public enum EPresencePriority + { + Highest = 2, + Middle = 1, + Lowest = 0 + } + + 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) + { + string strThisPresence = "Offline"; + EPresencePriority thisPriority = EPresencePriority.Lowest; + + if (userData == null) + { + thisPriority = EPresencePriority.Lowest; + strThisPresence = "Offline"; + } + + IsOnline = true; + + if (userData.currentLobbyID == -1) + { + thisPriority = EPresencePriority.Middle; + strThisPresence = "In Server List / Chat Room"; + } + else + { + var lobbyManager = ServiceLocator.Services.GetRequiredService(); + Lobby? plrLobby = lobbyManager.GetLobby(userData.currentLobbyID); + + thisPriority = EPresencePriority.Highest; + + if (plrLobby == null) + { + strThisPresence = "In A Lobby"; + } + else + { + 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; + } + } - // create new credential - Dictionary dictReqData = new(); - dictReqData.Add("ttl", TurnTTL); // 4 hours - dictReqData.Add("go_user_id", userID); // go user id - var jsonContent = JsonSerializer.Serialize(dictReqData); - using var requestContent = new StringContent(jsonContent, System.Text.Encoding.UTF8, "application/json"); - using (HttpClient client = new HttpClient(new SocketsHttpHandler() - { - ConnectCallback = async (context, cancellationToken) => - { - // Use DNS to look up the IP addresses of the target host: - // - IP v4: AddressFamily.InterNetwork - // - IP v6: AddressFamily.InterNetworkV6 - // - IP v4 or IP v6: AddressFamily.Unspecified - // note: this method throws a SocketException when there is no IP address for the host - var entry = await Dns.GetHostEntryAsync(context.DnsEndPoint.Host, AddressFamily.InterNetwork, cancellationToken); - - // Open the connection to the target host/port - var socket = new Socket(SocketType.Stream, ProtocolType.Tcp); - - // Turn off Nagle's algorithm since it degrades performance in most HttpClient scenarios. - socket.NoDelay = true; - - try - { - await socket.ConnectAsync(entry.AddressList, context.DnsEndPoint.Port, cancellationToken); + public class WebSocketMessage_Simple : WebSocketMessage + { - // If you want to choose a specific IP address to connect to the server - // await socket.ConnectAsync( - // entry.AddressList[Random.Shared.Next(0, entry.AddressList.Length)], - // context.DnsEndPoint.Port, cancellationToken); + } - // Return the NetworkStream to the caller - return new NetworkStream(socket, ownsSocket: true); - } - catch - { - socket.Dispose(); - throw; - } - } - })) - { - client.Timeout = TimeSpan.FromSeconds(10); - client.DefaultRequestHeaders.Add("Authorization", String.Format("Bearer {0}", TurnToken)); - client.DefaultRequestHeaders.Add("Accept", "application/json"); - //client.DefaultRequestHeaders.Add("Content-Type", "application/json"); - try - { - Console.WriteLine("Start req turn credentials at {0}", Environment.TickCount); - string strURI = String.Format("https://rtc.live.cloudflare.com/v1/turn/keys/{0}/credentials/generate-ice-servers", TurnKey); - HttpResponseMessage response = await client.PostAsync(strURI, requestContent); - - if (response.IsSuccessStatusCode) - { - Console.WriteLine("Finish req turn credentials at {0}", Environment.TickCount); - string responseBody = await response.Content.ReadAsStringAsync(); - TURNResponse? resp = JsonSerializer.Deserialize(responseBody); + public class WebSocketMessage_Probe : WebSocketMessage + { + public string url { get; set; } = String.Empty; + } - try - { - if (resp != null && resp.iceServers != null) - { - foreach (iceEntry? entry in resp.iceServers) - { - if (!string.IsNullOrEmpty(entry.username) && !string.IsNullOrEmpty(entry.credential)) - { - TURNCredentialContainer creds = new(entry.username, entry.credential); - g_DictTURNUsernames[userID] = entry.username; - return creds; - } - } - } - - return null; - } - catch - { + public class WebSocketMessage_StartMatch : WebSocketMessage + { + public string screenshot_url { get; set; } = String.Empty; + } - } + public abstract class WebSocketMessage + { + public int msg_id { get; set; } + } - return null; - } - } - catch - { + public class WebSocketMessage_NameChange : WebSocketMessage + { + public string name { get; set; } = String.Empty; + } - } - } + public class WebSocketMessage_FullMeshConnectivityCheckResponseFromUser : WebSocketMessage + { + public List connectivity_map { get; set; } = new(); + } - return null; - } + public class WebSocketMessage_Social_NewFriendRequest : WebSocketMessage + { + public string display_name { get; set; } = String.Empty; + } - public static async Task DeleteCredentialsForUser(Int64 userID) - { -#if DEBUG - await Task.Delay(1); - return; -#endif + public class WebSocketMessage_FullMeshConnectivityCheckOutcome : WebSocketMessage + { + public bool mesh_complete { get; set; } + public List missing_connections { get; set; } = new(); + } - GetTURNConfig(out int TurnTTL, out string TurnToken, out string TurnKey, out bool bShouldInvalidateTokensAutomatically); + public class WebSocketMessage_FullMeshConnectivityCheckOutcomeForHost : WebSocketMessage + { + public Dictionary connectivity_map { get; set; } = new(); + } - if (!bShouldInvalidateTokensAutomatically) - { - return; - } + public class WebSocketMessage_LobbyPasswordChange : WebSocketMessage + { + public string new_password { get; set; } = String.Empty; + } - if (g_DictTURNUsernames.ContainsKey(userID)) - { - string strTURNUsername = g_DictTURNUsernames[userID]; + public class WebSocketMessage_NetworkRoomChatMessageInbound : WebSocketMessage + { + public string? message { get; set; } + public bool action { get; set; } + } - g_DictTURNUsernames.Remove(userID, out string? strUsername); + public class WebSocketMessage_Social_FriendChatMessage_Inbound : WebSocketMessage + { + public Int64 target_user_id { get; set; } + public string? message { get; set; } + } - // revoke credential - Dictionary dictReqData = new(); - var jsonContent = JsonSerializer.Serialize(dictReqData); - using var requestContent = new StringContent(jsonContent, System.Text.Encoding.UTF8, "application/json"); + public class WebSocketMessage_Social_FriendChatMessage_Outbound : WebSocketMessage + { + public Int64 source_user_id { get; set; } + public Int64 target_user_id { get; set; } + public string? message { get; set; } + } - using (HttpClient client = new HttpClient(new SocketsHttpHandler() - { - ConnectCallback = async (context, cancellationToken) => - { - // Use DNS to look up the IP addresses of the target host: - // - IP v4: AddressFamily.InterNetwork - // - IP v6: AddressFamily.InterNetworkV6 - // - IP v4 or IP v6: AddressFamily.Unspecified - // note: this method throws a SocketException when there is no IP address for the host - var entry = await Dns.GetHostEntryAsync(context.DnsEndPoint.Host, AddressFamily.InterNetwork, cancellationToken); + public class WebSocketMessage_Social_FriendStatusChanged : WebSocketMessage + { + public string display_name { get; set; } = String.Empty; + public bool online { get; set; } + } - // Open the connection to the target host/port - var socket = new Socket(SocketType.Stream, ProtocolType.Tcp); + public class WebSocketMessage_Social_FriendsListDirty : WebSocketMessage + { + } - // Turn off Nagle's algorithm since it degrades performance in most HttpClient scenarios. - socket.NoDelay = true; + public class WebSocketMessage_Social_FriendsListFull : WebSocketMessage + { + } - try - { - await socket.ConnectAsync(entry.AddressList, context.DnsEndPoint.Port, cancellationToken); + public class WebSocketMessage_Social_FriendRequestAccepted : WebSocketMessage + { + public string display_name { get; set; } = String.Empty; + } - // If you want to choose a specific IP address to connect to the server - // await socket.ConnectAsync( - // entry.AddressList[Random.Shared.Next(0, entry.AddressList.Length)], - // context.DnsEndPoint.Port, cancellationToken); + public class WebSocketMessage_MatchmakingMessage : WebSocketMessage + { + public string? message { get; set; } + } - // Return the NetworkStream to the caller - return new NetworkStream(socket, ownsSocket: true); - } - catch - { - socket.Dispose(); - throw; - } - } - })) - { - client.Timeout = TimeSpan.FromSeconds(10); - client.DefaultRequestHeaders.Add("Authorization", String.Format("Bearer {0}", TurnToken)); - client.DefaultRequestHeaders.Add("Accept", "application/json"); - try - { - string strURI = String.Format("https://rtc.live.cloudflare.com/v1/turn/keys/{0}/credentials/{1}/revoke", TurnKey, strTURNUsername); - HttpResponseMessage response = await client.PostAsync(strURI, requestContent); - } - catch - { + public class WebSocketMessage_PONG : WebSocketMessage + { + } + public class WebSocketMessage_NetworkRoomChatMessageOutbound : WebSocketMessage + { + public string? message { get; set; } + public bool action { get; set; } = false; + public bool admin { get; set; } = false; + public bool name_change { get; set; } = false; + } - } - } - } - } - } - - public enum EPlayerType - { - SLOT_OPEN, - SLOT_CLOSED, - SLOT_EASY_AI, - SLOT_MED_AI, - SLOT_BRUTAL_AI, - SLOT_PLAYER - } - - public enum EWebSocketMessageID - { - UNKNOWN = -1, - NETWORK_ROOM_CHAT_FROM_CLIENT = 1, - NETWORK_ROOM_CHAT_FROM_SERVER = 2, - NETWORK_ROOM_CHANGE_ROOM = 3, - NETWORK_ROOM_MEMBER_LIST_UPDATE = 4, // TODO: This could be more optimal, send a diff instead of full list? - NETWORK_ROOM_MARK_READY = 5, - LOBBY_CURRENT_LOBBY_UPDATE = 6, - NETWORK_ROOM_LOBBY_LIST_UPDATE = 7, - UNUSED_PLACEHOLDER = 8, // this was relay upgrade, was removed. We can re-use it later, but service needs this placeholder - PLAYER_NAME_CHANGE = 9, - LOBBY_ROOM_CHAT_FROM_CLIENT = 10, - LOBBY_CHAT_FROM_SERVER = 11, - NETWORK_SIGNAL = 12, - START_GAME = 13, - PING = 14, - PONG = 15, - PROBE = 16, - NETWORK_CONNECTION_START_SIGNALLING = 17, - NETWORK_CONNECTION_DISCONNECT_PLAYER = 18, - NETWORK_CONNECTION_CLIENT_REQUEST_SIGNALLING = 19, - MATCHMAKING_ACTION_JOIN_PREARRANGED_LOBBY = 20, - MATCHMAKING_ACTION_START_GAME = 21, - MATCHMAKING_MESSAGE = 22, - START_GAME_COUNTDOWN_STARTED = 23, - LOBBY_REMOVE_PASSWORD = 24, - LOBBY_CHANGE_PASSWORD = 25, - FULL_MESH_CONNECTIVITY_CHECK_HOST_REQUESTS_BEGIN = 26, - FULL_MESH_CONNECTIVITY_CHECK_RESPONSE = 27, - FULL_MESH_CONNECTIVITY_CHECK_RESPONSE_COMPLETE_TO_HOST = 28, - SOCIAL_NEW_FRIEND_REQUEST = 29, - SOCIAL_FRIEND_CHAT_MESSAGE_CLIENT_TO_SERVER = 30, - SOCIAL_FRIEND_CHAT_MESSAGE_SERVER_TO_CLIENT = 31, - SOCIAL_FRIEND_ONLINE_STATUS_CHANGED = 32, - SOCIAL_SUBSCRIBE_REALTIME_UPDATES = 33, - SOCIAL_UNSUBSCRIBE_REALTIME_UPDATES = 34, - SOCIAL_FRIENDS_OVERALL_STATUS_UPDATE = 35, - SOCIAL_FRIEND_FRIEND_REQUEST_ACCEPTED_BY_TARGET = 36, - SOCIAL_FRIENDS_LIST_DIRTY = 37, - SOCIAL_CANT_ADD_FRIEND_LIST_FULL = 38, - PROBE_RESP = 39, - AC_REGISTER_PLAYER = 40, - AC_DEREGISTER_PLAYER = 41 - }; - - public static class UserPresence - { - public enum EPresencePriority - { - Highest = 2, - Middle = 1, - Lowest = 0 - } + public class WebSocketMessage_NetworkStartSignalling : WebSocketMessage + { + public Int64 lobby_id { get; set; } + public Int64 user_id { get; set; } + public Int64 preferred_port { get; set; } + } - public static string DetermineUserStatusFromAllSessions(Int64 user_id, out bool IsOnline) - { - List lstUserSessions = WebSocketManager.GetAllDataFromUser(user_id); - IsOnline = false; + public class WebSocketMessage_FriendsOverallStatusUpdate : WebSocketMessage + { + public int num_online { get; set; } = 0; + public int num_pending { get; set; } = 0; + } - string strOverallPresence = "Offline"; - UserPresence.EPresencePriority overallPriority = UserPresence.EPresencePriority.Lowest; + public class WebSocketMessage_ACRegisterPlayer : WebSocketMessage + { + public Int64 user_id { get; set; } + public string mwid { get; set; } = String.Empty; + } - foreach (UserSession userData in lstUserSessions) - { - string strThisPresence = "Offline"; - EPresencePriority thisPriority = EPresencePriority.Lowest; - - if (userData == null) - { - thisPriority = EPresencePriority.Lowest; - strThisPresence = "Offline"; - } - - IsOnline = true; - - if (userData.currentLobbyID == -1) - { - thisPriority = EPresencePriority.Middle; - strThisPresence = "In Server List / Chat Room"; - } - else - { - var lobbyManager = ServiceLocator.Services.GetRequiredService(); - Lobby? plrLobby = lobbyManager.GetLobby(userData.currentLobbyID); - - thisPriority = EPresencePriority.Highest; - - if (plrLobby == null) - { - strThisPresence = "In A Lobby"; - } - else - { - 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; - } - } + public class WebSocketMessage_ACDeregisterPlayer : WebSocketMessage + { + public Int64 user_id { get; set; } + public string mwid { get; set; } = String.Empty; + } - return strOverallPresence; - } - } - - - public class WebSocketMessage_Simple : WebSocketMessage - { - - } - - public class WebSocketMessage_Probe : WebSocketMessage - { - public string url { get; set; } = String.Empty; - } - - public class WebSocketMessage_StartMatch : WebSocketMessage - { - public string screenshot_url { get; set; } = String.Empty; - } - - public abstract class WebSocketMessage - { - public int msg_id { get; set; } - } - - public class WebSocketMessage_NameChange : WebSocketMessage - { - public string name { get; set; } = String.Empty; - } - - public class WebSocketMessage_FullMeshConnectivityCheckResponseFromUser : WebSocketMessage - { - public List connectivity_map { get; set; } = new(); - } - - public class WebSocketMessage_Social_NewFriendRequest : WebSocketMessage - { - public string display_name { get; set; } = String.Empty; - } - - public class WebSocketMessage_FullMeshConnectivityCheckOutcome: WebSocketMessage - { - public bool mesh_complete { get; set; } - public List missing_connections { get; set; } = new(); - } - - public class WebSocketMessage_FullMeshConnectivityCheckOutcomeForHost : WebSocketMessage - { - public Dictionary connectivity_map { get; set; } = new(); - } - - public class WebSocketMessage_LobbyPasswordChange : WebSocketMessage - { - public string new_password { get; set; } = String.Empty; - } - - public class WebSocketMessage_NetworkRoomChatMessageInbound : WebSocketMessage - { - public string? message { get; set; } - public bool action { get; set; } - } - - public class WebSocketMessage_Social_FriendChatMessage_Inbound : WebSocketMessage - { - public Int64 target_user_id { get; set; } - public string? message { get; set; } - } - - public class WebSocketMessage_Social_FriendChatMessage_Outbound : WebSocketMessage - { - public Int64 source_user_id { get; set; } - public Int64 target_user_id { get; set; } - public string? message { get; set; } - } - - public class WebSocketMessage_Social_FriendStatusChanged : WebSocketMessage - { - public string display_name { get; set; } = String.Empty; - public bool online { get; set; } - } - - public class WebSocketMessage_Social_FriendsListDirty: WebSocketMessage + public class WebSocketMessage_NetworkDisconnectPlayer : WebSocketMessage { + public Int64 lobby_id { get; set; } + public Int64 user_id { get; set; } } - public class WebSocketMessage_Social_FriendsListFull : WebSocketMessage + public class WebSocketMessage_LobbyChatMessageInbound : WebSocketMessage { + public string? message { get; set; } + public bool action { get; set; } + public bool announcement { get; set; } + public bool show_announcement_to_host { get; set; } } - public class WebSocketMessage_Social_FriendRequestAccepted : WebSocketMessage - { - public string display_name { get; set; } = String.Empty; + public class WebSocketMessage_RequestSignaling : WebSocketMessage + { + public Int64 target_user_id { get; set; } + } + public class WebSocketMessage_SignalBidirectional : WebSocketMessage + { + public Int64 target_user_id { get; set; } + public List? payload { get; set; } } - public class WebSocketMessage_MatchmakingMessage : WebSocketMessage - { - public string? message { get; set; } - } - - public class WebSocketMessage_PONG : WebSocketMessage - { - } - public class WebSocketMessage_NetworkRoomChatMessageOutbound : WebSocketMessage - { - public string? message { get; set; } - public bool action { get; set; } = false; - public bool admin { get; set; } = false; - public bool name_change { get; set; } = false; - } - - public class WebSocketMessage_NetworkStartSignalling : WebSocketMessage - { - public Int64 lobby_id{ get; set; } - public Int64 user_id { get; set; } - public Int64 preferred_port { get; set; } - } - - public class WebSocketMessage_FriendsOverallStatusUpdate : WebSocketMessage - { - public int num_online { get; set; } = 0; - public int num_pending { get; set; } = 0; - } - - public class WebSocketMessage_ACRegisterPlayer : WebSocketMessage - { - public Int64 user_id { get; set; } - public string mwid { get; set; } = String.Empty; - } - - public class WebSocketMessage_ACDeregisterPlayer : WebSocketMessage - { - public Int64 user_id { get; set; } - public string mwid { get; set; } = String.Empty; - } - - public class WebSocketMessage_NetworkDisconnectPlayer : WebSocketMessage - { - public Int64 lobby_id { get; set; } - public Int64 user_id { get; set; } - } - - public class WebSocketMessage_LobbyChatMessageInbound : WebSocketMessage - { - public string? message { get; set; } - public bool action { get; set; } - public bool announcement { get; set; } - public bool show_announcement_to_host { get; set; } - } - - public class WebSocketMessage_RequestSignaling : WebSocketMessage - { - public Int64 target_user_id { get; set; } - } - public class WebSocketMessage_SignalBidirectional : WebSocketMessage - { - public Int64 target_user_id { get; set; } - public List? payload { get; set; } - } - - public class WebSocketMessage_LobbyChatMessageOutbound : WebSocketMessage - { - public Int64 user_id { get; set; } - public string? message { get; set; } - public bool action { get; set; } - public bool announcement { get; set; } - public bool show_announcement_to_host { get; set; } // TODO: Remove, client doesnt care - } - public class WebSocketMessage_RelayUpgradeInbound : WebSocketMessage - { - public Int64 target_user_id { get; set; } - } - - public class WebSocketMessage_NetworkRoomMemberListUpdate : WebSocketMessage - { - public List members { get; set; } = new(); - } - - public class WebSocketMessage_CurrentLobbyUpdate : WebSocketMessage - { - } - - public class WebSocketMessage_CurrentNetworkRoomLobbyListUpdate : WebSocketMessage - { - } - - public class WebSocketMessage_MatchmakerJoinLobby : WebSocketMessage - { - - public Int64 lobby_id - { - get; set; - } - } + public class WebSocketMessage_LobbyChatMessageOutbound : WebSocketMessage + { + public Int64 user_id { get; set; } + public string? message { get; set; } + public bool action { get; set; } + public bool announcement { get; set; } + public bool show_announcement_to_host { get; set; } // TODO: Remove, client doesnt care + } + public class WebSocketMessage_RelayUpgradeInbound : WebSocketMessage + { + public Int64 target_user_id { get; set; } + } + + public class WebSocketMessage_NetworkRoomMemberListUpdate : WebSocketMessage + { + public List members { get; set; } = new(); + } + + public class WebSocketMessage_CurrentLobbyUpdate : WebSocketMessage + { + } + + public class WebSocketMessage_CurrentNetworkRoomLobbyListUpdate : WebSocketMessage + { + } + + public class WebSocketMessage_MatchmakerJoinLobby : WebSocketMessage + { - public class WebSocketMessage_MatchmakerStartGame : WebSocketMessage - { + public Int64 lobby_id + { + get; set; + } + } + + public class WebSocketMessage_MatchmakerStartGame : WebSocketMessage + { - } + } } \ No newline at end of file diff --git a/GenOnlineService/Controllers/Lobbies/LobbiesController.cs b/GenOnlineService/Controllers/Lobbies/LobbiesController.cs index 60b165b..a031c8e 100644 --- a/GenOnlineService/Controllers/Lobbies/LobbiesController.cs +++ b/GenOnlineService/Controllers/Lobbies/LobbiesController.cs @@ -61,8 +61,6 @@ public override Type GetReturnType() public class LobbiesController : ControllerBase { private readonly ILogger _logger; - private static List? s_cachedRooms = null; - private static readonly object s_roomsLock = new object(); private readonly LobbyManager _lobbyManager; private readonly IDbContextFactory _dbFactory; @@ -73,24 +71,7 @@ public LobbiesController(LobbyManager lobbyManager, IDbContextFactory?> GetCachedRooms(JsonSerializerOptions options) - { - if (s_cachedRooms == null) - { - lock (s_roomsLock) - { - if (s_cachedRooms == null) - { - string strFileData = System.IO.File.ReadAllText(Path.Combine("data", "rooms.json")); - s_cachedRooms = JsonSerializer.Deserialize>(strFileData, options); - } - } - } - return await Task.FromResult(s_cachedRooms); - } + } // FOR LATENCY ESTIMATIONS // Convert degrees to radians @@ -163,8 +144,7 @@ public async Task Get() if (sourceData != null) { - // Use cached rooms data - List? lstRooms = await GetCachedRooms(options); + var lstRooms = Constants.Rooms.Values; if (lstRooms != null) { foreach (RoomData room in lstRooms) diff --git a/GenOnlineService/Controllers/Rooms/RoomsController.cs b/GenOnlineService/Controllers/Rooms/RoomsController.cs index 21c1be9..3a1e065 100644 --- a/GenOnlineService/Controllers/Rooms/RoomsController.cs +++ b/GenOnlineService/Controllers/Rooms/RoomsController.cs @@ -25,54 +25,37 @@ namespace GenOnlineService.Controllers { - public class RouteHandler_GET_Rooms_Result : APIResult - { - public override Type GetReturnType() - { - return this.GetType(); - } - - public List? rooms { get; set; } = null; - } - - [ApiController] - [Route("env/{environment}/contract/{contract_version}/[controller]")] - public class RoomsController : ControllerBase - { - private readonly ILogger _logger; - - public RoomsController(ILogger logger) - { - _logger = logger; - } - - [HttpGet(Name = "GetRooms")] - [Authorize(Roles = "GameClient,ChatClient,GameLauncher,Monitor")] - public async Task Get() - { - RouteHandler_GET_Rooms_Result result = new RouteHandler_GET_Rooms_Result(); - - using (var reader = new StreamReader(HttpContext.Request.Body)) - { - string jsonData = await reader.ReadToEndAsync(); - var options = new JsonSerializerOptions - { - PropertyNameCaseInsensitive = true - }; - - try - { - string strFileData = await System.IO.File.ReadAllTextAsync(Path.Combine("data", "rooms.json")); - List? lstRooms = JsonSerializer.Deserialize>(strFileData, options); - result.rooms = lstRooms; - } - catch - { - return result; - } - - return result; - } - } - } + public class RouteHandler_GET_Rooms_Result : APIResult + { + public override Type GetReturnType() + { + return this.GetType(); + } + + public List? rooms { get; set; } = null; + } + + [ApiController] + [Route("env/{environment}/contract/{contract_version}/[controller]")] + public class RoomsController : ControllerBase + { + private readonly ILogger _logger; + + public RoomsController(ILogger logger) + { + _logger = logger; + } + + [HttpGet(Name = "GetRooms")] + [Authorize(Roles = "GameClient,ChatClient,GameLauncher,Monitor")] + public Task Get() + { + APIResult result = new RouteHandler_GET_Rooms_Result() + { + rooms = Constants.Rooms.Values.ToList() + }; + + return Task.FromResult(result); + } + } } diff --git a/GenOnlineService/GenOnlineService.csproj b/GenOnlineService/GenOnlineService.csproj index cb2d5e0..f052084 100644 --- a/GenOnlineService/GenOnlineService.csproj +++ b/GenOnlineService/GenOnlineService.csproj @@ -45,6 +45,7 @@ + diff --git a/GenOnlineService/LobbyManager.cs b/GenOnlineService/LobbyManager.cs index 0708fe9..1bf4ebf 100644 --- a/GenOnlineService/LobbyManager.cs +++ b/GenOnlineService/LobbyManager.cs @@ -24,6 +24,7 @@ using System.Collections; using System.Collections.Concurrent; using System.Collections.Generic; +using System.ComponentModel; using System.Drawing; using System.IO; using System.Net; @@ -37,441 +38,441 @@ namespace GenOnlineService { - public class Lobby - { - public Int64 LobbyID { get; private set; } = -1; - public Int64 Owner { get; private set; } = -1; - public string Name { get; private set; } = ""; - public ELobbyState State { get; private set; } = ELobbyState.UNKNOWN; - public string MapName { get; private set; } = ""; - public string MapPath { get; private set; } = ""; - public bool IsMapOfficial { get; private set; } = false; - public UInt64 MatchID { get; private set; } = 0; - - public DateTime TimeCreated { get; private set; } = DateTime.UtcNow; - - [JsonIgnore] - public bool PendingFullMeshConnectivityChecks { get; private set; } = false; - - [JsonIgnore] - public Int64 TimeStartFullMeshChecks { get; private set; } = -1; - - private const int MSToWaitForFullMeshChecks = 5; // really shouldnt take more than 5 seconds... this might even be too much - - [JsonIgnore] - public ConcurrentDictionary> FullMeshConnectivityChecks { get; set; } = new(); - - public void StartFullMeshConnectivityCheck() - { - PendingFullMeshConnectivityChecks = true; - FullMeshConnectivityChecks = new(); - TimeStartFullMeshChecks = Environment.TickCount64; - } - - public async Task StoreFullMeshConnectivityResponse(Int64 sourceUser, List connectivityMap) - { - FullMeshConnectivityChecks[sourceUser] = new ConcurrentList(connectivityMap); - - // check again for being done - await ProcessPendingFullMeshConnectivityChecks(); - } - - public async Task ProcessPendingFullMeshConnectivityChecks() - { - // TODO: Add a timeout to this - if (PendingFullMeshConnectivityChecks) - { - bool bDoneChecks = false; - int totalMapEntriesExpected = GetNumberOfHumans(); - //int numConnectionsExpectedPerUser = totalMapEntriesExpected - 1; // minus self - - // must have a connectivity map for each lobby member - bDoneChecks = FullMeshConnectivityChecks.Count == totalMapEntriesExpected; - - // did we timeout? - if (!bDoneChecks && (Environment.TickCount64 - TimeStartFullMeshChecks) >= MSToWaitForFullMeshChecks) - { - bDoneChecks = true; - } - - List lstMissingConnections = new(); - - if (bDoneChecks) - { - // now verify each user has provided data for all other users - foreach (var userMap in FullMeshConnectivityChecks) - { - // foreach member in the lobby, check they are in userMap.Value - foreach (LobbyMember member in Members) - { - if (member.IsHuman()) - { - // useful for test -// if (member.UserID != userMap.Key && member.UserID == 1) -// { -// // register it -// MissingConnectionEntry missingConnectionEntry = new(); -// missingConnectionEntry.source_user_id = userMap.Key; -// missingConnectionEntry.target_user_id = member.UserID; -// lstMissingConnections.Add(missingConnectionEntry); -// } - - // wont have a connection to ourself - if (member.UserID != userMap.Key && !userMap.Value.Contains(member.UserID)) - { - // register it - MissingConnectionEntry missingConnectionEntry = new(); - missingConnectionEntry.source_user_id = userMap.Key; - missingConnectionEntry.target_user_id = member.UserID; - lstMissingConnections.Add(missingConnectionEntry); - } - } - } - } - - bool bDisableMeshCheck = false; - if (Program.g_Config != null) - { - IConfiguration? coreSettings = Program.g_Config.GetSection("Core"); - - if (coreSettings != null) - { - bDisableMeshCheck = coreSettings.GetValue("disable_full_mesh_check"); - } - } - - - - // inform host that we are done - // start full mesh connectivity checks - WebSocketMessage_FullMeshConnectivityCheckOutcome outcome = new WebSocketMessage_FullMeshConnectivityCheckOutcome(); - outcome.msg_id = (int)EWebSocketMessageID.FULL_MESH_CONNECTIVITY_CHECK_RESPONSE_COMPLETE_TO_HOST; - - if (bDisableMeshCheck) - { - outcome.mesh_complete = true; - outcome.missing_connections = new List(); - } - else - { - outcome.mesh_complete = lstMissingConnections.Count == 0; - 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.GetSessionFromUser(Owner, EUserSessionType.GameClient); // host should be a game client - if (hostSession != null) - { - byte[] bytesJSON = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(outcome)); - hostSession.QueueWebsocketSend(bytesJSON); - } - - // reset state - PendingFullMeshConnectivityChecks = false; - TimeStartFullMeshChecks = -1; - } - } - } + public class Lobby + { + public Int64 LobbyID { get; private set; } = -1; + public Int64 Owner { get; private set; } = -1; + public string Name { get; private set; } = ""; + public ELobbyState State { get; private set; } = ELobbyState.UNKNOWN; + public string MapName { get; private set; } = ""; + public string MapPath { get; private set; } = ""; + public bool IsMapOfficial { get; private set; } = false; + public UInt64 MatchID { get; private set; } = 0; + + public DateTime TimeCreated { get; private set; } = DateTime.UtcNow; + + [JsonIgnore] + public bool PendingFullMeshConnectivityChecks { get; private set; } = false; + + [JsonIgnore] + public Int64 TimeStartFullMeshChecks { get; private set; } = -1; + + private const int MSToWaitForFullMeshChecks = 5; // really shouldnt take more than 5 seconds... this might even be too much + + [JsonIgnore] + public ConcurrentDictionary> FullMeshConnectivityChecks { get; set; } = new(); + + public void StartFullMeshConnectivityCheck() + { + PendingFullMeshConnectivityChecks = true; + FullMeshConnectivityChecks = new(); + TimeStartFullMeshChecks = Environment.TickCount64; + } + + public async Task StoreFullMeshConnectivityResponse(Int64 sourceUser, List connectivityMap) + { + FullMeshConnectivityChecks[sourceUser] = new ConcurrentList(connectivityMap); + + // check again for being done + await ProcessPendingFullMeshConnectivityChecks(); + } + + public async Task ProcessPendingFullMeshConnectivityChecks() + { + // TODO: Add a timeout to this + if (PendingFullMeshConnectivityChecks) + { + bool bDoneChecks = false; + int totalMapEntriesExpected = GetNumberOfHumans(); + //int numConnectionsExpectedPerUser = totalMapEntriesExpected - 1; // minus self + + // must have a connectivity map for each lobby member + bDoneChecks = FullMeshConnectivityChecks.Count == totalMapEntriesExpected; + + // did we timeout? + if (!bDoneChecks && (Environment.TickCount64 - TimeStartFullMeshChecks) >= MSToWaitForFullMeshChecks) + { + bDoneChecks = true; + } - public void AddPassword(string password) - { - Password = password; - IsPassworded = true; - } + List lstMissingConnections = new(); - public void RemovePassword() - { - Password = String.Empty; - IsPassworded = false; - } + if (bDoneChecks) + { + // now verify each user has provided data for all other users + foreach (var userMap in FullMeshConnectivityChecks) + { + // foreach member in the lobby, check they are in userMap.Value + foreach (LobbyMember member in Members) + { + if (member.IsHuman()) + { + // useful for test + // if (member.UserID != userMap.Key && member.UserID == 1) + // { + // // register it + // MissingConnectionEntry missingConnectionEntry = new(); + // missingConnectionEntry.source_user_id = userMap.Key; + // missingConnectionEntry.target_user_id = member.UserID; + // lstMissingConnections.Add(missingConnectionEntry); + // } + + // wont have a connection to ourself + if (member.UserID != userMap.Key && !userMap.Value.Contains(member.UserID)) + { + // register it + MissingConnectionEntry missingConnectionEntry = new(); + missingConnectionEntry.source_user_id = userMap.Key; + missingConnectionEntry.target_user_id = member.UserID; + lstMissingConnections.Add(missingConnectionEntry); + } + } + } + } - public double GetLatitude() { return m_dHostLatitude; } - public double GetLongitude() { return m_dHostLongitude; } + bool bDisableMeshCheck = false; + if (Program.g_Config != null) + { + IConfiguration? coreSettings = Program.g_Config.GetSection("Core"); - public async Task SetMatchID(UInt64 a_matchID) - { + if (coreSettings != null) + { + bDisableMeshCheck = coreSettings.GetValue("disable_full_mesh_check"); + } + } + + + + // inform host that we are done + // start full mesh connectivity checks + WebSocketMessage_FullMeshConnectivityCheckOutcome outcome = new WebSocketMessage_FullMeshConnectivityCheckOutcome(); + outcome.msg_id = (int)EWebSocketMessageID.FULL_MESH_CONNECTIVITY_CHECK_RESPONSE_COMPLETE_TO_HOST; + + if (bDisableMeshCheck) + { + outcome.mesh_complete = true; + outcome.missing_connections = new List(); + } + else + { + outcome.mesh_complete = lstMissingConnections.Count == 0; + 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.GetSessionFromUser(Owner, EUserSessionType.GameClient); // host should be a game client + if (hostSession != null) + { + byte[] bytesJSON = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(outcome)); + hostSession.QueueWebsocketSend(bytesJSON); + } + + // reset state + PendingFullMeshConnectivityChecks = false; + TimeStartFullMeshChecks = -1; + } + } + } + + public void AddPassword(string password) + { + Password = password; + IsPassworded = true; + } + + public void RemovePassword() + { + Password = String.Empty; + IsPassworded = false; + } + + public double GetLatitude() { return m_dHostLatitude; } + public double GetLongitude() { return m_dHostLongitude; } + + public async Task SetMatchID(UInt64 a_matchID) + { #if DEBUG - MatchID = 123456; + MatchID = 123456; #else MatchID = a_matchID; #endif - // store on each player - foreach (LobbyMember member in Members) - { - if (member.GetSession().TryGetTarget(out UserSession? session)) - { - session.RegisterHistoricMatchID(MatchID, member.SlotIndex, member.Side); - } - } + // store on each player + foreach (LobbyMember member in Members) + { + if (member.GetSession().TryGetTarget(out UserSession? session)) + { + session.RegisterHistoricMatchID(MatchID, member.SlotIndex, member.Side); + } + } - DirtyRetransmit(); - } + DirtyRetransmit(); + } + + // NOTE: Do not use Members.Length on a lobby. It will include empty slots. Use NumCurrentPlayers instead + public int NumCurrentPlayers + { + get + { + // TODO: More optimal to store this instead of looping for it every time + int currentPlayers = 0; + foreach (LobbyMember member in Members) + { + if (member.SlotState != EPlayerType.SLOT_CLOSED && member.SlotState != EPlayerType.SLOT_OPEN) + { + ++currentPlayers; + } + } - // NOTE: Do not use Members.Length on a lobby. It will include empty slots. Use NumCurrentPlayers instead - public int NumCurrentPlayers - { - get - { - // TODO: More optimal to store this instead of looping for it every time - int currentPlayers = 0; - foreach (LobbyMember member in Members) - { - if (member.SlotState != EPlayerType.SLOT_CLOSED && member.SlotState != EPlayerType.SLOT_OPEN) - { - ++currentPlayers; - } - } - - return currentPlayers; - } - } - //public int MaxPlayers { get; private set; } = 0; - public int MaxPlayers - { - get - { - // TODO: More optimal to store this instead of looping for it every time - int maxPlayers = 0; - foreach (LobbyMember member in Members) - { - if (member.SlotState != EPlayerType.SLOT_CLOSED) - { - ++maxPlayers; - } - } - - return maxPlayers; - } - } + return currentPlayers; + } + } + //public int MaxPlayers { get; private set; } = 0; + public int MaxPlayers + { + get + { + // TODO: More optimal to store this instead of looping for it every time + int maxPlayers = 0; + foreach (LobbyMember member in Members) + { + if (member.SlotState != EPlayerType.SLOT_CLOSED) + { + ++maxPlayers; + } + } + + return maxPlayers; + } + } - public bool IsVanillaTeamsOnly { get; private set; } = false; - public UInt32 StartingCash { get; private set; } = 0; - public bool IsLimitSuperweapons { get; private set; } = false; - public bool IsTrackingStats { get; private set; } = false; - public bool IsPassworded { get; private set; } = false; + public bool IsVanillaTeamsOnly { get; private set; } = false; + public UInt32 StartingCash { get; private set; } = 0; + public bool IsLimitSuperweapons { get; private set; } = false; + public bool IsTrackingStats { get; private set; } = false; + public bool IsPassworded { get; private set; } = false; - [JsonIgnore] // never serialize the password, we only need it on the service - public string Password { get; private set; } = String.Empty; + [JsonIgnore] // never serialize the password, we only need it on the service + public string Password { get; private set; } = String.Empty; - public bool AllowObservers { get; private set; } = false; - public UInt32 ExeCRC { get; private set; } = 0; - public UInt32 IniCRC { get; private set; } = 0; + public bool AllowObservers { get; private set; } = false; + public UInt32 ExeCRC { get; private set; } = 0; + public UInt32 IniCRC { get; private set; } = 0; - public Int16 NetworkRoomID { get; private set; } = -1; + public Int16[] NetworkRoomIDs { get; private set; } = []; - public int RNGSeed { get; private set; } = -1; + public int RNGSeed { get; private set; } = -1; - public ELobbyType LobbyType { get; private set; } = ELobbyType.CustomGame; + public ELobbyType LobbyType { get; private set; } = ELobbyType.CustomGame; - public string Region { get; private set; } = ""; - public int EstimatedLatency { get; private set; } = 999999; + public string Region { get; private set; } = ""; + public int EstimatedLatency { get; private set; } = 999999; - public UInt16 MaximumCameraHeight { get; private set; } = GenOnlineService.Constants.g_DefaultCameraMaxHeight; + public UInt16 MaximumCameraHeight { get; private set; } = GenOnlineService.Constants.g_DefaultCameraMaxHeight; [JsonIgnore] // This is not serialized as the client doesn't need to know, the service checks it public ELobbyJoinability LobbyJoinability { get; private set; } = ELobbyJoinability.Public; // public by default public const int maxLobbySize = 8; - public LobbyMember[] Members { get; private set; } = new LobbyMember[maxLobbySize]; - - public EKnownAnticheatID AnticheatID { get; private set; } = EKnownAnticheatID.NONE; - - [JsonIgnore] - public Dictionary TimeMemberLeft { get; private set; } = new(); - - // Records the first time each player's in-game WebSocket connection dropped (i.e., when they first "quit" - // while the match was in progress). Only the first disconnect is stored — reconnects do not reset it. - // Used by DetermineLobbyWinnerIfNotPresent to find who abandoned first (= loser) vs last (= winner). - [JsonIgnore] - public Dictionary TimePlayerAbandonedIngame { get; private set; } = new(); - - /// - /// Records the moment a player's WebSocket dropped while the lobby was in INGAME state. - /// Only the FIRST disconnect is stored; subsequent reconnect/disconnect cycles are ignored - /// so that a player who briefly loses connection is not penalised more than the player who - /// intentionally killed the game first. - /// - public void RecordPlayerIngameAbandon(Int64 userId) - { - if (!TimePlayerAbandonedIngame.ContainsKey(userId)) - { - TimePlayerAbandonedIngame[userId] = DateTime.UtcNow; - Console.WriteLine("[Lobby {0}] Recorded in-game abandon for user {1} at {2:O}", LobbyID, userId, TimePlayerAbandonedIngame[userId]); + public LobbyMember[] Members { get; private set; } = new LobbyMember[maxLobbySize]; + + public EKnownAnticheatID AnticheatID { get; private set; } = EKnownAnticheatID.NONE; + + [JsonIgnore] + public Dictionary TimeMemberLeft { get; private set; } = new(); + + // Records the first time each player's in-game WebSocket connection dropped (i.e., when they first "quit" + // while the match was in progress). Only the first disconnect is stored — reconnects do not reset it. + // Used by DetermineLobbyWinnerIfNotPresent to find who abandoned first (= loser) vs last (= winner). + [JsonIgnore] + public Dictionary TimePlayerAbandonedIngame { get; private set; } = new(); + + /// + /// Records the moment a player's WebSocket dropped while the lobby was in INGAME state. + /// Only the FIRST disconnect is stored; subsequent reconnect/disconnect cycles are ignored + /// so that a player who briefly loses connection is not penalised more than the player who + /// intentionally killed the game first. + /// + public void RecordPlayerIngameAbandon(Int64 userId) + { + if (!TimePlayerAbandonedIngame.ContainsKey(userId)) + { + TimePlayerAbandonedIngame[userId] = DateTime.UtcNow; + Console.WriteLine("[Lobby {0}] Recorded in-game abandon for user {1} at {2:O}", LobbyID, userId, TimePlayerAbandonedIngame[userId]); - } - } - - /// - /// Removes the in-game abandon timestamp for a player who successfully reconnected. - /// This ensures a future disconnect records the correct (later) quit time. - /// - public void ClearPlayerIngameAbandon(Int64 userId) - { - if (TimePlayerAbandonedIngame.Remove(userId)) - { - Console.WriteLine("[Lobby {0}] Cleared in-game abandon record for reconnected user {1}", LobbyID, userId); - } - } + } + } + + /// + /// Removes the in-game abandon timestamp for a player who successfully reconnected. + /// This ensures a future disconnect records the correct (later) quit time. + /// + public void ClearPlayerIngameAbandon(Int64 userId) + { + if (TimePlayerAbandonedIngame.Remove(userId)) + { + Console.WriteLine("[Lobby {0}] Cleared in-game abandon record for reconnected user {1}", LobbyID, userId); + } + } - private bool m_bIsDirty = false; + private bool m_bIsDirty = false; - [JsonIgnore] - private Int64 m_LastInitialSync = Environment.TickCount64; + [JsonIgnore] + private Int64 m_LastInitialSync = Environment.TickCount64; - [JsonIgnore] - private int m_InitialSyncs = 0; + [JsonIgnore] + private int m_InitialSyncs = 0; - [JsonIgnore] - private Int64 m_NextProbe = 0; + [JsonIgnore] + private Int64 m_NextProbe = 0; - // used for ping calculation but never sent to clients - [JsonIgnore] - private double m_dHostLatitude = 0; + // used for ping calculation but never sent to clients + [JsonIgnore] + private double m_dHostLatitude = 0; - [JsonIgnore] - private double m_dHostLongitude = 0; + [JsonIgnore] + private double m_dHostLongitude = 0; - public Lobby(Int64 lobby_id, UserSession owner, string name, ELobbyState state, string map_name, string map_path, bool vanilla_teams, UInt32 starting_cash, bool limit_superweapons, - bool track_stats, bool passworded, string password, bool map_official, int rng_seed, Int16 network_room, bool allow_observers, UInt16 max_cam_height, UInt32 exe_crc, UInt32 ini_crc, - int max_players, ELobbyType lobbyType, EKnownAnticheatID inAnticheatID) - { - LobbyID = lobby_id; - Owner = owner.m_UserID; + public Lobby(Int64 lobby_id, UserSession owner, string name, ELobbyState state, string map_name, string map_path, bool vanilla_teams, UInt32 starting_cash, bool limit_superweapons, + bool track_stats, bool passworded, string password, bool map_official, int rng_seed, Int16[] network_rooms, bool allow_observers, UInt16 max_cam_height, UInt32 exe_crc, UInt32 ini_crc, + int max_players, ELobbyType lobbyType, EKnownAnticheatID inAnticheatID) + { + LobbyID = lobby_id; + Owner = owner.m_UserID; - string strAnticheatName = "OTHER AC"; - if (inAnticheatID == EKnownAnticheatID.NONE) - { - strAnticheatName = "\u26C9NO AC"; - } - else if (inAnticheatID == EKnownAnticheatID.GO_INTEGRATED_AC) - { - strAnticheatName = "\u26CAGOAC"; - } - else if (inAnticheatID == EKnownAnticheatID.EASY_ANTICHEAT) - { - strAnticheatName = "\u26CAEAC"; - } + string strAnticheatName = "OTHER AC"; + if (inAnticheatID == EKnownAnticheatID.NONE) + { + strAnticheatName = "\u26C9NO AC"; + } + else if (inAnticheatID == EKnownAnticheatID.GO_INTEGRATED_AC) + { + strAnticheatName = "\u26CAGOAC"; + } + else if (inAnticheatID == EKnownAnticheatID.EASY_ANTICHEAT) + { + strAnticheatName = "\u26CAEAC"; + } - Name = String.Format("[{0}][{1}] {2}", owner.m_strContinent, strAnticheatName, name); - Region = String.Format("{0}", owner.GetFullContinentName()); - m_dHostLatitude = owner.m_dLatitude; - m_dHostLongitude = owner.m_dLongitude; - State = state; - MapName = map_name; - MapPath = FixMapPathForGame(map_path); - IsMapOfficial = map_official; - IsVanillaTeamsOnly = vanilla_teams; - StartingCash = starting_cash; - IsLimitSuperweapons = limit_superweapons; - IsTrackingStats = track_stats; - IsPassworded = passworded; - Password = password; - AllowObservers = allow_observers; - ExeCRC = exe_crc; - IniCRC = ini_crc; - NetworkRoomID = network_room; - RNGSeed = rng_seed; - MaximumCameraHeight = max_cam_height; - LobbyType = lobbyType; - Members = new LobbyMember[maxLobbySize]; - AnticheatID = inAnticheatID; - - // create default slots - for (UInt16 i = 0; i < maxLobbySize; ++i) - { - LobbyMember placeholderMember = new LobbyMember(this, null, -1, String.Empty, String.Empty, 0, -1, -1, -1, i < max_players ? EPlayerType.SLOT_OPEN : EPlayerType.SLOT_CLOSED, i, true); - Members[i] = placeholderMember; - } - } + Name = String.Format("[{0}][{1}] {2}", owner.m_strContinent, strAnticheatName, name); + Region = String.Format("{0}", owner.GetFullContinentName()); + m_dHostLatitude = owner.m_dLatitude; + m_dHostLongitude = owner.m_dLongitude; + State = state; + MapName = map_name; + MapPath = FixMapPathForGame(map_path); + IsMapOfficial = map_official; + IsVanillaTeamsOnly = vanilla_teams; + StartingCash = starting_cash; + IsLimitSuperweapons = limit_superweapons; + IsTrackingStats = track_stats; + IsPassworded = passworded; + Password = password; + AllowObservers = allow_observers; + ExeCRC = exe_crc; + IniCRC = ini_crc; + NetworkRoomIDs = network_rooms; + RNGSeed = rng_seed; + MaximumCameraHeight = max_cam_height; + LobbyType = lobbyType; + Members = new LobbyMember[maxLobbySize]; + AnticheatID = inAnticheatID; + + // create default slots + for (UInt16 i = 0; i < maxLobbySize; ++i) + { + LobbyMember placeholderMember = new LobbyMember(this, null, -1, String.Empty, String.Empty, 0, -1, -1, -1, i < max_players ? EPlayerType.SLOT_OPEN : EPlayerType.SLOT_CLOSED, i, true); + Members[i] = placeholderMember; + } + } - public event Action? OnLobbyNeedsDestroyed; + 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 - bool bNeedsHostMigrate = Owner == leavingUserID; + public async Task OnAfterPlayerLeft(Int64 leavingUserID) + { + // NOTE: By the time this is called, the member is no longer in the members list + bool bNeedsHostMigrate = Owner == leavingUserID; - // we need human members, not real members - int numHumanMembers = GetNumberOfHumans(); + // we need human members, not real members + int numHumanMembers = GetNumberOfHumans(); - if (numHumanMembers == 0) - { - Console.ForegroundColor = ConsoleColor.Cyan; - Console.WriteLine("DeleteLobby: Source A"); - Console.ForegroundColor = ConsoleColor.Gray; + if (numHumanMembers == 0) + { + Console.ForegroundColor = ConsoleColor.Cyan; + Console.WriteLine("DeleteLobby: Source A"); + Console.ForegroundColor = ConsoleColor.Gray; - OnLobbyNeedsDestroyed?.Invoke(this); - } - else - { - // Host migration is only meaningful in the lobby setup phase. - // During an active game the P2P host is determined by the game engine, - // and migrating the server-side owner causes confusing mid-game lobby - // state broadcasts to surviving clients. - if (bNeedsHostMigrate && State != ELobbyState.INGAME) - { - DoHostMigration(); - } - } - } + OnLobbyNeedsDestroyed?.Invoke(this); + } + else + { + // Host migration is only meaningful in the lobby setup phase. + // During an active game the P2P host is determined by the game engine, + // and migrating the server-side owner causes confusing mid-game lobby + // state broadcasts to surviving clients. + if (bNeedsHostMigrate && State != ELobbyState.INGAME) + { + DoHostMigration(); + } + } + } + + public void CloseOpenSlots() + { + foreach (LobbyMember member in Members) + { + if (member.SlotState == EPlayerType.SLOT_OPEN) + { + member.SetPlayerSlotState(EPlayerType.SLOT_CLOSED); + } + } - public void CloseOpenSlots() - { - foreach (LobbyMember member in Members) - { - if (member.SlotState == EPlayerType.SLOT_OPEN) - { - member.SetPlayerSlotState(EPlayerType.SLOT_CLOSED); - } - } + DirtyRetransmit(); + } - DirtyRetransmit(); - } + public void DoHostMigration() + { + Int64 oldOwner = Owner; - public void DoHostMigration() - { - Int64 oldOwner = Owner; + foreach (LobbyMember member in Members) + { + if (member.SlotState == EPlayerType.SLOT_PLAYER) + { + if (member.UserID != oldOwner) + { + // found a viable host + UInt16 oldSlot = member.SlotIndex; - foreach (LobbyMember member in Members) - { - if (member.SlotState == EPlayerType.SLOT_PLAYER) - { - if (member.UserID != oldOwner) - { - // found a viable host - UInt16 oldSlot = member.SlotIndex; - - // update owner - Owner = member.UserID; - - // move them to slot 0 (host) - member.UpdateSlotIndex(0); - Members[0] = member; - Members[oldSlot] = new LobbyMember(this, null, -1, String.Empty, String.Empty, 0, -1, -1, -1, EPlayerType.SLOT_OPEN, oldSlot, true); - - // mark as ready - member.SetReadyState(true); - - // mark as dirty - DirtyRetransmit(); - - // we are done - break; - } - } - } - } + // update owner + Owner = member.UserID; - private void CalculateNextProbeTime(bool bIsFirstProbe) - { + // move them to slot 0 (host) + member.UpdateSlotIndex(0); + Members[0] = member; + Members[oldSlot] = new LobbyMember(this, null, -1, String.Empty, String.Empty, 0, -1, -1, -1, EPlayerType.SLOT_OPEN, oldSlot, true); + + // mark as ready + member.SetReadyState(true); + + // mark as dirty + DirtyRetransmit(); + + // we are done + break; + } + } + } + } + + private void CalculateNextProbeTime(bool bIsFirstProbe) + { #if DEBUG - m_NextProbe = Environment.TickCount64 + (bIsFirstProbe ? 5000 : 30000); + m_NextProbe = Environment.TickCount64 + (bIsFirstProbe ? 5000 : 30000); #else if (bIsFirstProbe) // 30s { @@ -483,112 +484,112 @@ private void CalculateNextProbeTime(bool bIsFirstProbe) m_NextProbe = Environment.TickCount64 + nextProbeInterval * 60000; } #endif - } + } + + public async Task Tick() + { + if (m_NextProbe != 0 && Environment.TickCount64 >= m_NextProbe) + { + // send probe + { + foreach (LobbyMember memberEntry in Members) + { + // per user endpoint + string? strUploadURI = await S3CredentialManager.GetPresignedURL(EMetadataFileType.FILE_TYPE_SCREENSHOT, EScreenshotType.SCREENSHOT_TYPE_GAMEPLAY, MatchID, memberEntry.UserID, memberEntry.SlotIndex); - public async Task Tick() - { - if (m_NextProbe != 0 && Environment.TickCount64 >= m_NextProbe) - { - // send probe - { - foreach (LobbyMember memberEntry in Members) - { - // per user endpoint - string? strUploadURI = await S3CredentialManager.GetPresignedURL(EMetadataFileType.FILE_TYPE_SCREENSHOT, EScreenshotType.SCREENSHOT_TYPE_GAMEPLAY, MatchID, memberEntry.UserID, memberEntry.SlotIndex); - - if (strUploadURI != null) // should never be null really - { - WebSocketMessage_Probe probe = new WebSocketMessage_Probe(); - probe.msg_id = (int)EWebSocketMessageID.PROBE; - probe.url = strUploadURI; - byte[] bytesJSON = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(probe)); - - if (memberEntry.GetSession().TryGetTarget(out UserSession? session)) - { - session.QueueWebsocketSend(bytesJSON); - } - } - } - } - - // calculate next probe time - CalculateNextProbeTime(false); - } + if (strUploadURI != null) // should never be null really + { + WebSocketMessage_Probe probe = new WebSocketMessage_Probe(); + probe.msg_id = (int)EWebSocketMessageID.PROBE; + probe.url = strUploadURI; + byte[] bytesJSON = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(probe)); - if (m_InitialSyncs < 5 && Environment.TickCount64 - m_LastInitialSync > 200) - { - m_LastInitialSync = Environment.TickCount64; - m_bIsDirty = true; - ++m_InitialSyncs; - } + if (memberEntry.GetSession().TryGetTarget(out UserSession? session)) + { + session.QueueWebsocketSend(bytesJSON); + } + } + } + } - if (m_bIsDirty) - { - WebSocketMessage_CurrentLobbyUpdate lobbyUpdate = new WebSocketMessage_CurrentLobbyUpdate(); - lobbyUpdate.msg_id = (int)EWebSocketMessageID.LOBBY_CURRENT_LOBBY_UPDATE; - - byte[] bytesJSON = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(lobbyUpdate)); - - foreach (LobbyMember memberEntry in Members) - { - if (memberEntry.GetSession().TryGetTarget(out UserSession? session)) - { - UserSession? sess = WebSocketManager.GetSessionFromUser(session.m_UserID, session.GetSessionType()); - if (sess != null) - { - Console.WriteLine("[DIRTY LOBBY] Sending WS lobby update for lobby {0}", LobbyID); - sess.QueueWebsocketSend(bytesJSON); - } - } - } - - // transmit to those in network room - //WebSocketManager.SendNewOrDeletedLobbyToAllNetworkRoomMembers(NetworkRoomID); - - m_bIsDirty = false; - } - } + // calculate next probe time + CalculateNextProbeTime(false); + } - private readonly SemaphoreSlim g_SlotLock = new SemaphoreSlim(1, 1); - public async Task AddMember(UserSession playerSession, string strDisplayName, UInt16 userPreferredPort, bool bHasMap, UserLobbyPreferences lobbyPrefs) - { - LobbyMember? existingMember = GetMemberFromUserID(playerSession.m_UserID); - if (existingMember != null) // we're already in this lobby - { - return false; - } + if (m_InitialSyncs < 5 && Environment.TickCount64 - m_LastInitialSync > 200) + { + m_LastInitialSync = Environment.TickCount64; + m_bIsDirty = true; + ++m_InitialSyncs; + } - // NOTE: AddMember is called async, so timing + slot determination could result in players being inserted in the same slot - await g_SlotLock.WaitAsync(); - try - { - // find first open slot - bool bFoundSlot = false; - UInt16 slotIndex = 0; - foreach (var memberEntry in Members) - { - if (memberEntry.SlotState == EPlayerType.SLOT_OPEN) - { - // found a gap, use this slot index - bFoundSlot = true; - break; - } - ++slotIndex; - } - - if (!bFoundSlot) - { - return false; - } + if (m_bIsDirty) + { + WebSocketMessage_CurrentLobbyUpdate lobbyUpdate = new WebSocketMessage_CurrentLobbyUpdate(); + lobbyUpdate.msg_id = (int)EWebSocketMessageID.LOBBY_CURRENT_LOBBY_UPDATE; + + byte[] bytesJSON = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(lobbyUpdate)); + + foreach (LobbyMember memberEntry in Members) + { + if (memberEntry.GetSession().TryGetTarget(out UserSession? session)) + { + UserSession? sess = WebSocketManager.GetSessionFromUser(session.m_UserID, session.GetSessionType()); + if (sess != null) + { + Console.WriteLine("[DIRTY LOBBY] Sending WS lobby update for lobby {0}", LobbyID); + sess.QueueWebsocketSend(bytesJSON); + } + } + } + + // transmit to those in network room + //WebSocketManager.SendNewOrDeletedLobbyToAllNetworkRoomMembers(NetworkRoomID); + + m_bIsDirty = false; + } + } + + private readonly SemaphoreSlim g_SlotLock = new SemaphoreSlim(1, 1); + public async Task AddMember(UserSession playerSession, string strDisplayName, UInt16 userPreferredPort, bool bHasMap, UserLobbyPreferences lobbyPrefs) + { + LobbyMember? existingMember = GetMemberFromUserID(playerSession.m_UserID); + if (existingMember != null) // we're already in this lobby + { + return false; + } + + // NOTE: AddMember is called async, so timing + slot determination could result in players being inserted in the same slot + await g_SlotLock.WaitAsync(); + try + { + // find first open slot + bool bFoundSlot = false; + UInt16 slotIndex = 0; + foreach (var memberEntry in Members) + { + if (memberEntry.SlotState == EPlayerType.SLOT_OPEN) + { + // found a gap, use this slot index + bFoundSlot = true; + break; + } + ++slotIndex; + } + + if (!bFoundSlot) + { + return false; + } // Check social requirements (dont allow blocked in, and check friends only) // SOCIAL: If the lobby owner has source user blocked, remove the lobby - // NOTE: Only check this for custom match, quick match checks it during matchmaking bucket stage - if (LobbyType == ELobbyType.CustomGame) - { - SharedUserData? lobbyOwnerSharedData = WebSocketManager.GetSharedDataForUser(Owner); // owner must be a game client + // NOTE: Only check this for custom match, quick match checks it during matchmaking bucket stage + if (LobbyType == ELobbyType.CustomGame) + { + SharedUserData? lobbyOwnerSharedData = WebSocketManager.GetSharedDataForUser(Owner); // owner must be a game client - if (lobbyOwnerSharedData != null) + if (lobbyOwnerSharedData != null) { // dont allow join if blocked if (lobbyOwnerSharedData.GetSocialContainer().Blocked.Contains(playerSession.m_UserID)) @@ -602,60 +603,60 @@ public async Task AddMember(UserSession playerSession, string strDisplayNa // If it's friends only, return false if they aren't friends if (!lobbyOwnerSharedData.GetSocialContainer().Friends.Contains(playerSession.m_UserID)) { - return false; + return false; } } } } - // de dupe names - string strOriginalDisplayName = strDisplayName; - int dupesSeen = 0; - string strNameLower = strDisplayName.ToLower(); - foreach (var memberEntry in Members) - { - if (memberEntry.DisplayNameNotDeduped.ToLower() == strNameLower) - { - ++dupesSeen; - } - } + // de dupe names + string strOriginalDisplayName = strDisplayName; + int dupesSeen = 0; + string strNameLower = strDisplayName.ToLower(); + foreach (var memberEntry in Members) + { + if (memberEntry.DisplayNameNotDeduped.ToLower() == strNameLower) + { + ++dupesSeen; + } + } - if (dupesSeen > 0) - { - strDisplayName = String.Format("{0} ({1})", strDisplayName, dupesSeen); - } + if (dupesSeen > 0) + { + strDisplayName = String.Format("{0} ({1})", strDisplayName, dupesSeen); + } - // only apply lobby prefs if not QM - LobbyMember? newMember = null; - if (LobbyType == ELobbyType.CustomGame) - { + // only apply lobby prefs if not QM + LobbyMember? newMember = null; + if (LobbyType == ELobbyType.CustomGame) + { - // if vanilla teams, dont apply favorite - int sideToUse = lobbyPrefs.favorite_side; - if (IsVanillaTeamsOnly) - { - sideToUse = -1; - } - - // if our preferred color is already in use, revert to random - int colorToUse = lobbyPrefs.favorite_color; - foreach (var memberEntry in Members) - { - if (memberEntry.Color == lobbyPrefs.favorite_color) - { - colorToUse = -1; - break; - } - } - - newMember = new LobbyMember(this, playerSession, playerSession.m_UserID, strDisplayName, strOriginalDisplayName, userPreferredPort, sideToUse, colorToUse, -1, EPlayerType.SLOT_PLAYER, slotIndex, bHasMap); - } - else - { - // NOTE: In quick match, we need to pick their team, client doesn't do it for us. - int[] allowedTeams = - [ - 2, // USA + // if vanilla teams, dont apply favorite + int sideToUse = lobbyPrefs.favorite_side; + if (IsVanillaTeamsOnly) + { + sideToUse = -1; + } + + // if our preferred color is already in use, revert to random + int colorToUse = lobbyPrefs.favorite_color; + foreach (var memberEntry in Members) + { + if (memberEntry.Color == lobbyPrefs.favorite_color) + { + colorToUse = -1; + break; + } + } + + newMember = new LobbyMember(this, playerSession, playerSession.m_UserID, strDisplayName, strOriginalDisplayName, userPreferredPort, sideToUse, colorToUse, -1, EPlayerType.SLOT_PLAYER, slotIndex, bHasMap); + } + else + { + // NOTE: In quick match, we need to pick their team, client doesn't do it for us. + int[] allowedTeams = + [ + 2, // USA 3, // CHINA 4, // GLA 5, // USA Super Weapon @@ -667,906 +668,935 @@ public async Task AddMember(UserSession playerSession, string strDisplayNa 11, // GLA Toxin 12, // GLA Demo 13 // GLA Stealth - ]; - - int sideToUse = allowedTeams[Random.Shared.Next(0, allowedTeams.Length)]; - - // team is random for now, matchmaker will assign teams on start - newMember = new LobbyMember(this, playerSession, playerSession.m_UserID, strDisplayName, strOriginalDisplayName, userPreferredPort, sideToUse, -1, -1, EPlayerType.SLOT_PLAYER, slotIndex, bHasMap); - } + ]; - Members[slotIndex] = newMember; - TimeMemberLeft[playerSession.m_UserID] = DateTime.UnixEpoch; - - // leave network room we were in - playerSession.UpdateSessionNetworkRoom(-1); - - // store our lobby ID - playerSession.UpdateSessionLobbyID(LobbyID); - - // START NETWORK SIGNALLING - // send all existing player to new player and vice-versa - foreach (LobbyMember memberEntry in Members) - { - if (memberEntry != newMember) // NOT us - { - if (memberEntry.GetSession().TryGetTarget(out UserSession? remoteSession)) - { - if (playerSession != null) - { - // send signal start to joining player - WebSocketMessage_NetworkStartSignalling joiningPlayerMsg = new WebSocketMessage_NetworkStartSignalling(); - joiningPlayerMsg.msg_id = (int)EWebSocketMessageID.NETWORK_CONNECTION_START_SIGNALLING; - joiningPlayerMsg.lobby_id = LobbyID; - joiningPlayerMsg.user_id = memberEntry.UserID; - joiningPlayerMsg.preferred_port = memberEntry.Port; - playerSession.QueueWebsocketSend(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(joiningPlayerMsg))); - - // send register player - WebSocketMessage_ACRegisterPlayer joiningPlayerACMsg = new WebSocketMessage_ACRegisterPlayer(); - joiningPlayerACMsg.msg_id = (int)EWebSocketMessageID.AC_REGISTER_PLAYER; - joiningPlayerACMsg.user_id = memberEntry.UserID; - joiningPlayerACMsg.mwid = memberEntry.MiddlewareUserID; - playerSession.QueueWebsocketSend(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(joiningPlayerACMsg))); - } - - if (remoteSession != null) - { - // send the reverse to the existing player - WebSocketMessage_NetworkStartSignalling existingPlayerMsg = new WebSocketMessage_NetworkStartSignalling(); - existingPlayerMsg.msg_id = (int)EWebSocketMessageID.NETWORK_CONNECTION_START_SIGNALLING; - existingPlayerMsg.lobby_id = LobbyID; - existingPlayerMsg.user_id = playerSession.m_UserID; - existingPlayerMsg.preferred_port = userPreferredPort; - remoteSession.QueueWebsocketSend(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(existingPlayerMsg))); - - // send register player - WebSocketMessage_ACRegisterPlayer existingPlayerACMsg = new WebSocketMessage_ACRegisterPlayer(); - existingPlayerACMsg.msg_id = (int)EWebSocketMessageID.AC_REGISTER_PLAYER; - existingPlayerACMsg.user_id = playerSession.m_UserID; - existingPlayerACMsg.mwid = playerSession.GetMiddlewareID(); - remoteSession.QueueWebsocketSend(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(existingPlayerACMsg))); - } - } - } - } - // END NETWORK SIGNALLING + int sideToUse = allowedTeams[Random.Shared.Next(0, allowedTeams.Length)]; - // also update the lobby for everyone inside of it - DirtyRetransmit(); + // team is random for now, matchmaker will assign teams on start + newMember = new LobbyMember(this, playerSession, playerSession.m_UserID, strDisplayName, strOriginalDisplayName, userPreferredPort, sideToUse, -1, -1, EPlayerType.SLOT_PLAYER, slotIndex, bHasMap); + } - Console.WriteLine("User {0} joined lobby {1}: {2} (Slot was {3})", playerSession.m_UserID, LobbyID, true, slotIndex); - return true; - } - finally - { - g_SlotLock.Release(); - } - } + Members[slotIndex] = newMember; + TimeMemberLeft[playerSession.m_UserID] = DateTime.UnixEpoch; - public async Task RemoveMember(LobbyMember member) - { - // TODO_LOBBY: Optimize this - Int64 UserID = member.UserID; + // leave network room we were in + playerSession.UpdateSessionNetworkRoom(-1); - Console.WriteLine("User {0} left lobby {1}", UserID, LobbyID); + // store our lobby ID + playerSession.UpdateSessionLobbyID(LobbyID); - LobbyMember placeholderMember = new LobbyMember(this, null, -1, String.Empty, String.Empty, 0, -1, -1, -1, EPlayerType.SLOT_OPEN, member.SlotIndex, true); - Members[member.SlotIndex] = placeholderMember; - TimeMemberLeft[UserID] = DateTime.UtcNow; + // START NETWORK SIGNALLING + // send all existing player to new player and vice-versa + foreach (LobbyMember memberEntry in Members) + { + if (memberEntry != newMember) // NOT us + { + if (memberEntry.GetSession().TryGetTarget(out UserSession? remoteSession)) + { + if (playerSession != null) + { + // send signal start to joining player + WebSocketMessage_NetworkStartSignalling joiningPlayerMsg = new WebSocketMessage_NetworkStartSignalling(); + joiningPlayerMsg.msg_id = (int)EWebSocketMessageID.NETWORK_CONNECTION_START_SIGNALLING; + joiningPlayerMsg.lobby_id = LobbyID; + joiningPlayerMsg.user_id = memberEntry.UserID; + joiningPlayerMsg.preferred_port = memberEntry.Port; + playerSession.QueueWebsocketSend(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(joiningPlayerMsg))); + + // send register player + WebSocketMessage_ACRegisterPlayer joiningPlayerACMsg = new WebSocketMessage_ACRegisterPlayer(); + joiningPlayerACMsg.msg_id = (int)EWebSocketMessageID.AC_REGISTER_PLAYER; + joiningPlayerACMsg.user_id = memberEntry.UserID; + joiningPlayerACMsg.mwid = memberEntry.MiddlewareUserID; + playerSession.QueueWebsocketSend(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(joiningPlayerACMsg))); + } - // AC dergister - WebSocketMessage_ACDeregisterPlayer remotePlayerAcMsg = new WebSocketMessage_ACDeregisterPlayer(); - remotePlayerAcMsg.msg_id = (int)EWebSocketMessageID.AC_DEREGISTER_PLAYER; - remotePlayerAcMsg.user_id = member.UserID; - remotePlayerAcMsg.mwid = member.MiddlewareUserID; - foreach (LobbyMember remoteMember in Members) - { - if (remoteMember.GetSession().TryGetTarget(out UserSession? remoteSession)) - { - if (remoteSession != null) - { - Console.WriteLine("Sent AC deregister for user {0} to user {1}", member.UserID, remoteMember.UserID); - remoteSession.QueueWebsocketSend(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(remotePlayerAcMsg))); - } - } - } + if (remoteSession != null) + { + // send the reverse to the existing player + WebSocketMessage_NetworkStartSignalling existingPlayerMsg = new WebSocketMessage_NetworkStartSignalling(); + existingPlayerMsg.msg_id = (int)EWebSocketMessageID.NETWORK_CONNECTION_START_SIGNALLING; + existingPlayerMsg.lobby_id = LobbyID; + existingPlayerMsg.user_id = playerSession.m_UserID; + existingPlayerMsg.preferred_port = userPreferredPort; + remoteSession.QueueWebsocketSend(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(existingPlayerMsg))); + + // send register player + WebSocketMessage_ACRegisterPlayer existingPlayerACMsg = new WebSocketMessage_ACRegisterPlayer(); + existingPlayerACMsg.msg_id = (int)EWebSocketMessageID.AC_REGISTER_PLAYER; + existingPlayerACMsg.user_id = playerSession.m_UserID; + existingPlayerACMsg.mwid = playerSession.GetMiddlewareID(); + remoteSession.QueueWebsocketSend(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(existingPlayerACMsg))); + } + } + } + } + // END NETWORK SIGNALLING - // send signal to disconnect (only if not ingame, ingame we let the client handle it so a service disconnect doesnt end the game) - if (State != ELobbyState.INGAME) - { - WebSocketMessage_NetworkDisconnectPlayer remotePlayerMsg = new WebSocketMessage_NetworkDisconnectPlayer(); - remotePlayerMsg.msg_id = (int)EWebSocketMessageID.NETWORK_CONNECTION_DISCONNECT_PLAYER; - remotePlayerMsg.lobby_id = LobbyID; - remotePlayerMsg.user_id = member.UserID; - - // START NETWORK DISCONNECT - foreach (LobbyMember remoteMember in Members) - { - if (remoteMember.GetSession().TryGetTarget(out UserSession? remoteSession)) - { - if (remoteSession != null) - { - Console.WriteLine("Sent network disconnect for user {0} to user {1}", member.UserID, remoteMember.UserID); - remoteSession.QueueWebsocketSend(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(remotePlayerMsg))); - } - } - } - // END NETWORK DISCONNECT - } + // also update the lobby for everyone inside of it + DirtyRetransmit(); - await OnAfterPlayerLeft(UserID); + Console.WriteLine("User {0} joined lobby {1}: {2} (Slot was {3})", playerSession.m_UserID, LobbyID, true, slotIndex); + return true; + } + finally + { + g_SlotLock.Release(); + } + } + + public async Task RemoveMember(LobbyMember member) + { + // TODO_LOBBY: Optimize this + Int64 UserID = member.UserID; + + Console.WriteLine("User {0} left lobby {1}", UserID, LobbyID); + + LobbyMember placeholderMember = new LobbyMember(this, null, -1, String.Empty, String.Empty, 0, -1, -1, -1, EPlayerType.SLOT_OPEN, member.SlotIndex, true); + Members[member.SlotIndex] = placeholderMember; + TimeMemberLeft[UserID] = DateTime.UtcNow; + + // AC dergister + WebSocketMessage_ACDeregisterPlayer remotePlayerAcMsg = new WebSocketMessage_ACDeregisterPlayer(); + remotePlayerAcMsg.msg_id = (int)EWebSocketMessageID.AC_DEREGISTER_PLAYER; + remotePlayerAcMsg.user_id = member.UserID; + remotePlayerAcMsg.mwid = member.MiddlewareUserID; + foreach (LobbyMember remoteMember in Members) + { + if (remoteMember.GetSession().TryGetTarget(out UserSession? remoteSession)) + { + if (remoteSession != null) + { + Console.WriteLine("Sent AC deregister for user {0} to user {1}", member.UserID, remoteMember.UserID); + remoteSession.QueueWebsocketSend(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(remotePlayerAcMsg))); + } + } + } - DirtyRetransmit(); - } + // send signal to disconnect (only if not ingame, ingame we let the client handle it so a service disconnect doesnt end the game) + if (State != ELobbyState.INGAME) + { + WebSocketMessage_NetworkDisconnectPlayer remotePlayerMsg = new WebSocketMessage_NetworkDisconnectPlayer(); + remotePlayerMsg.msg_id = (int)EWebSocketMessageID.NETWORK_CONNECTION_DISCONNECT_PLAYER; + remotePlayerMsg.lobby_id = LobbyID; + remotePlayerMsg.user_id = member.UserID; + + // START NETWORK DISCONNECT + foreach (LobbyMember remoteMember in Members) + { + if (remoteMember.GetSession().TryGetTarget(out UserSession? remoteSession)) + { + if (remoteSession != null) + { + Console.WriteLine("Sent network disconnect for user {0} to user {1}", member.UserID, remoteMember.UserID); + remoteSession.QueueWebsocketSend(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(remotePlayerMsg))); + } + } + } + // END NETWORK DISCONNECT + } - public int GetNumberOfHumans() - { - int numHumanMembers = 0; - foreach (LobbyMember memberEntry in Members) - { - if (memberEntry.SlotState == EPlayerType.SLOT_PLAYER) // we only care about human members, AI can't play alone - { - ++numHumanMembers; - } - } + await OnAfterPlayerLeft(UserID); - return numHumanMembers; - } + DirtyRetransmit(); + } - public void GetParticipantBreakdown(out int numHumans, out int numAI, out int numOpen, out int numClosed) - { - numHumans = 0; - numAI = 0; - numOpen = 0; - numClosed = 0; + public int GetNumberOfHumans() + { + int numHumanMembers = 0; + foreach (LobbyMember memberEntry in Members) + { + if (memberEntry.SlotState == EPlayerType.SLOT_PLAYER) // we only care about human members, AI can't play alone + { + ++numHumanMembers; + } + } - foreach (LobbyMember memberEntry in Members) - { - if (memberEntry.SlotState == EPlayerType.SLOT_OPEN) - { - ++numOpen; - } - else if (memberEntry.SlotState == EPlayerType.SLOT_CLOSED) - { - ++numClosed; - } - else if (memberEntry.SlotState == EPlayerType.SLOT_EASY_AI || memberEntry.SlotState == EPlayerType.SLOT_MED_AI || memberEntry.SlotState == EPlayerType.SLOT_BRUTAL_AI) - { - ++numAI; - } - else if (memberEntry.SlotState == EPlayerType.SLOT_PLAYER) - { - ++numHumans; - } - } - } + return numHumanMembers; + } + + public void GetParticipantBreakdown(out int numHumans, out int numAI, out int numOpen, out int numClosed) + { + numHumans = 0; + numAI = 0; + numOpen = 0; + numClosed = 0; + + foreach (LobbyMember memberEntry in Members) + { + if (memberEntry.SlotState == EPlayerType.SLOT_OPEN) + { + ++numOpen; + } + else if (memberEntry.SlotState == EPlayerType.SLOT_CLOSED) + { + ++numClosed; + } + else if (memberEntry.SlotState == EPlayerType.SLOT_EASY_AI || memberEntry.SlotState == EPlayerType.SLOT_MED_AI || memberEntry.SlotState == EPlayerType.SLOT_BRUTAL_AI) + { + ++numAI; + } + else if (memberEntry.SlotState == EPlayerType.SLOT_PLAYER) + { + ++numHumans; + } + } + } - public void DirtyRetransmit() - { - m_bIsDirty = true; - } + public void DirtyRetransmit() + { + m_bIsDirty = true; + } - public async Task DirtyRetransmitToSingleMember(Int64 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); + public async Task DirtyRetransmitToSingleMember(Int64 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); - WebSocketMessage_CurrentLobbyUpdate lobbyUpdate = new WebSocketMessage_CurrentLobbyUpdate(); - lobbyUpdate.msg_id = (int)EWebSocketMessageID.LOBBY_CURRENT_LOBBY_UPDATE; - byte[] bytesJSON = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(lobbyUpdate)); + WebSocketMessage_CurrentLobbyUpdate lobbyUpdate = new WebSocketMessage_CurrentLobbyUpdate(); + lobbyUpdate.msg_id = (int)EWebSocketMessageID.LOBBY_CURRENT_LOBBY_UPDATE; + byte[] bytesJSON = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(lobbyUpdate)); - session.QueueWebsocketSend(bytesJSON); - } - } + session.QueueWebsocketSend(bytesJSON); + } + } + + public LobbyMember? GetMemberFromUserID(Int64 user_id) + { + foreach (LobbyMember memberEntry in Members) + { + if (memberEntry.UserID == user_id && memberEntry.SlotState == EPlayerType.SLOT_PLAYER) + { + return memberEntry; + } + } + return null; + } + + public LobbyMember? GetMemberFromSlot(int slotIndex) + { + if (slotIndex >= 0 && slotIndex < Members.Length) + { + return Members[slotIndex]; + } - public LobbyMember? GetMemberFromUserID(Int64 user_id) - { - foreach (LobbyMember memberEntry in Members) - { - if (memberEntry.UserID == user_id && memberEntry.SlotState == EPlayerType.SLOT_PLAYER) - { - return memberEntry; - } - } - return null; - } + return null; + } + + private static String FixMapPathForGame(string strMapPath) + { + strMapPath = String.Format(@"{0}\{1}", Path.GetFileNameWithoutExtension(strMapPath), strMapPath); + return strMapPath; + } + + public async Task UpdateMap(AppDbContext _db, string strMap, string strMapPath, bool bOfficialMap, int newMaxPlayers) + { + int oldMaxPlayers = MaxPlayers; + MapName = strMap; + MapPath = FixMapPathForGame(strMapPath); + IsMapOfficial = bOfficialMap; + + // close any slots that were open and now below the max, open anything not already occupied, up to new max + foreach (var slot in Members) + { + if (slot.SlotIndex < newMaxPlayers) + { + if (slot.SlotState == EPlayerType.SLOT_CLOSED) + { + slot.SetPlayerSlotState(EPlayerType.SLOT_OPEN); + } + } - public LobbyMember? GetMemberFromSlot(int slotIndex) - { - if (slotIndex >= 0 && slotIndex < Members.Length) - { - return Members[slotIndex]; - } - - return null; - } - - private static String FixMapPathForGame(string strMapPath) - { - strMapPath = String.Format(@"{0}\{1}", Path.GetFileNameWithoutExtension(strMapPath), strMapPath); - return strMapPath; - } - - public async Task UpdateMap(AppDbContext _db, string strMap, string strMapPath, bool bOfficialMap, int newMaxPlayers) - { - int oldMaxPlayers = MaxPlayers; - MapName = strMap; - MapPath = FixMapPathForGame(strMapPath); - IsMapOfficial = bOfficialMap; - - // close any slots that were open and now below the max, open anything not already occupied, up to new max - foreach (var slot in Members) - { - if (slot.SlotIndex < newMaxPlayers) - { - if (slot.SlotState == EPlayerType.SLOT_CLOSED) - { - slot.SetPlayerSlotState(EPlayerType.SLOT_OPEN); - } - } - - if (slot.SlotIndex >= newMaxPlayers) - { - if (slot.SlotState == EPlayerType.SLOT_OPEN) - { - slot.SetPlayerSlotState(EPlayerType.SLOT_CLOSED); - } - } - } + if (slot.SlotIndex >= newMaxPlayers) + { + if (slot.SlotState == EPlayerType.SLOT_OPEN) + { + slot.SetPlayerSlotState(EPlayerType.SLOT_CLOSED); + } + } + } - // only if official, since we cant guarantee if they log in on another machine that the map is installed - if (bOfficialMap) - { - await Database.Users.SetFavorite_Map(_db, Owner, strMapPath); - } + // only if official, since we cant guarantee if they log in on another machine that the map is installed + if (bOfficialMap) + { + await Database.Users.SetFavorite_Map(_db, Owner, strMapPath); + } - DirtyRetransmit(); - } + DirtyRetransmit(); + } - public async Task UpdateStartingCash(AppDbContext _db, UInt32 newStartingCash) - { - StartingCash = newStartingCash; + public async Task UpdateStartingCash(AppDbContext _db, UInt32 newStartingCash) + { + StartingCash = newStartingCash; - await Database.Users.SetFavorite_StartingMoney(_db, Owner, (int)newStartingCash); + await Database.Users.SetFavorite_StartingMoney(_db, Owner, (int)newStartingCash); - DirtyRetransmit(); - } + DirtyRetransmit(); + } - public async Task UpdateLimitSuperweapons(AppDbContext _db, bool bLimitSuperweapons) - { - IsLimitSuperweapons = bLimitSuperweapons; + public async Task UpdateLimitSuperweapons(AppDbContext _db, bool bLimitSuperweapons) + { + IsLimitSuperweapons = bLimitSuperweapons; - await Database.Users.SetFavorite_LimitSuperweapons(_db, Owner, bLimitSuperweapons); + await Database.Users.SetFavorite_LimitSuperweapons(_db, Owner, bLimitSuperweapons); - DirtyRetransmit(); - } + DirtyRetransmit(); + } - public void ForceReady() - { - foreach (var member in Members) - { - member.SetReadyState(true); - } + public void ForceReady() + { + foreach (var member in Members) + { + member.SetReadyState(true); + } - DirtyRetransmit(); - } + DirtyRetransmit(); + } - private int m_cachedAtStart_numHumans = -1; - private int m_cachedAtStart_numOpen = -1; - private int m_cachedAtStart_numClosed = -1; - private int m_cachedAtStart_numAI = -1; + private int m_cachedAtStart_numHumans = -1; + private int m_cachedAtStart_numOpen = -1; + private int m_cachedAtStart_numClosed = -1; + private int m_cachedAtStart_numAI = -1; - // TODO: Really, client also shouldnt upload data we arent going to process in this situation, its wasteful - public bool WasPVPAtStart() - { - // debug + // TODO: Really, client also shouldnt upload data we arent going to process in this situation, its wasteful + public bool WasPVPAtStart() + { + // debug #if DEBUG - return true; + return true; #endif - // use cached data, we can call this after people left etc + // use cached data, we can call this after people left etc - // We are a PVP lobby if we have > 1 human, and 0 AI - bool bWasPVP = m_cachedAtStart_numHumans > 1 && m_cachedAtStart_numAI == 0; - return bWasPVP; - } + // We are a PVP lobby if we have > 1 human, and 0 AI + bool bWasPVP = m_cachedAtStart_numHumans > 1 && m_cachedAtStart_numAI == 0; + return bWasPVP; + } - public bool HadAIAtStart() - { - // debug + public bool HadAIAtStart() + { + // debug #if DEBUG - return false; + return false; #endif - // use cached data, we can call this after people left etc - return m_cachedAtStart_numAI > 0; - } - - public async Task UpdateState(ELobbyState state) - { - State = state; + // use cached data, we can call this after people left etc + return m_cachedAtStart_numAI > 0; + } + + public async Task UpdateState(ELobbyState state) + { + State = state; + + // if start, init our AC probe + if (state == ELobbyState.INGAME) + { + // cache starting data, we use this later in replay/ss upload + GetParticipantBreakdown(out int numHumans, out int numAI, out int numOpen, out int numClosed); + m_cachedAtStart_numHumans = numHumans; + m_cachedAtStart_numOpen = numOpen; + m_cachedAtStart_numClosed = numClosed; + m_cachedAtStart_numAI = numAI; + + // lobby cant have AI and must have at least 2 human players at some point + if (WasPVPAtStart() && !HadAIAtStart()) + { + try + { + // create placeholder + using var scope = ServiceLocator.Services.CreateScope(); + var factory = scope.ServiceProvider.GetRequiredService>(); + await using var db = await factory.CreateDbContextAsync(); + await Database.MatchHistory.CreatePlaceholderMatchHistory(db, this); + } + catch (Exception ex) + { + Console.WriteLine($"[ERROR] UpdateState placeholder creation failed: {ex.Message}"); + SentrySdk.CaptureException(ex); + } - // if start, init our AC probe - if (state == ELobbyState.INGAME) - { - // cache starting data, we use this later in replay/ss upload - GetParticipantBreakdown(out int numHumans, out int numAI, out int numOpen, out int numClosed); - m_cachedAtStart_numHumans = numHumans; - m_cachedAtStart_numOpen = numOpen; - m_cachedAtStart_numClosed = numClosed; - m_cachedAtStart_numAI = numAI; - - // lobby cant have AI and must have at least 2 human players at some point - if (WasPVPAtStart() && !HadAIAtStart()) - { - try - { - // create placeholder - using var scope = ServiceLocator.Services.CreateScope(); - var factory = scope.ServiceProvider.GetRequiredService>(); - await using var db = await factory.CreateDbContextAsync(); - await Database.MatchHistory.CreatePlaceholderMatchHistory(db, this); - } - catch (Exception ex) - { - Console.WriteLine($"[ERROR] UpdateState placeholder creation failed: {ex.Message}"); - SentrySdk.CaptureException(ex); - } - - // calculate first probe time - CalculateNextProbeTime(true); - } - else - { - // disable probes - m_NextProbe = 0; - } - - - } + // calculate first probe time + CalculateNextProbeTime(true); + } + else + { + // disable probes + m_NextProbe = 0; + } - DirtyRetransmit(); - } - public void UpdateJoinability(ELobbyJoinability newJoinability) - { - // must be a custom match - if (LobbyType == ELobbyType.CustomGame) - { - LobbyJoinability = newJoinability; } - } - public void UpdateMaxCameraHeight(UInt16 maxCamHeight) - { - if (maxCamHeight >= 210 && maxCamHeight <= 1000) - { - MaximumCameraHeight = maxCamHeight; - DirtyRetransmit(); - } - } + DirtyRetransmit(); + } - public void ResetReadyStates() - { - foreach (LobbyMember member in Members) - { - member.SetReadyState(false); - } + public void UpdateJoinability(ELobbyJoinability newJoinability) + { + // must be a custom match + if (LobbyType == ELobbyType.CustomGame) + { + LobbyJoinability = newJoinability; + } + } + + public void UpdateMaxCameraHeight(UInt16 maxCamHeight) + { + if (maxCamHeight >= 210 && maxCamHeight <= 1000) + { + MaximumCameraHeight = maxCamHeight; + DirtyRetransmit(); + } + } - DirtyRetransmit(); - } + public void ResetReadyStates() + { + foreach (LobbyMember member in Members) + { + member.SetReadyState(false); + } - } - public class LobbyMember - { - public Int64 UserID { get; private set; } = -999999; - public string DisplayName { get; private set; } = ""; + DirtyRetransmit(); + } + + } + public class LobbyMember + { + public Int64 UserID { get; private set; } = -999999; + public string DisplayName { get; private set; } = ""; + + [JsonIgnore] + public string DisplayNameNotDeduped { get; set; } = ""; // internal only, used for determining dedupe counts + + private bool m_Ready; + public bool IsReady + { + get + { + // host is always ready + if (SlotIndex == 0) + { + return true; + } - [JsonIgnore] - public string DisplayNameNotDeduped { get; set; } = ""; // internal only, used for determining dedupe counts + // AI is always ready + if (IsAI()) + { + return true; + } - private bool m_Ready; - public bool IsReady - { - get - { - // host is always ready - if (SlotIndex == 0) - { - return true; - } - - // AI is always ready - if (IsAI()) - { - return true; - } - - return m_Ready; - } - set { m_Ready = value; } - } - - public UInt16 Port { get; private set; } = 0; - - public void UpdateSlotIndex(UInt16 index) - { - SlotIndex = index; - } - public int Side { get; private set; } = 0; - public int Team { get; private set; } = -1; - public int Color { get; private set; } = 0; - public int StartingPosition { get; private set; } = 0; - public bool HasMap { get; private set; } = false; - - public EPlayerType SlotState { get; private set; } = 0; - public UInt16 SlotIndex { get; private set; } = 0; - public string Region { get; private set; } = "Unknown"; - public string MiddlewareUserID { get; private set; } = String.Empty; - - [JsonIgnore] // cant serialize refs - private WeakReference CurrentLobby = new(null); - - [JsonIgnore] - private WeakReference PlayerSession = new(null); - - public WeakReference GetSession() - { - return PlayerSession; - } - - public LobbyMember(Lobby owningLobby, UserSession? owningSession, Int64 UserID_in, string DisplayName_in, string strUndedupedDisplayName, UInt16 Port_in, int Side_in, int Color_in, int StartingPosition_in, EPlayerType SlotState_in, UInt16 SlotIndex_in, bool bHasMap_in) - { - CurrentLobby = new WeakReference(owningLobby); - PlayerSession = new WeakReference(owningSession); - - UserID = UserID_in; - DisplayName = DisplayName_in; - DisplayNameNotDeduped = strUndedupedDisplayName; - Port = Port_in; - Side = Side_in; - Color = Color_in; - StartingPosition = StartingPosition_in; - HasMap = bHasMap_in; - SlotState = SlotState_in; - SlotIndex = SlotIndex_in; - - // default slots are created with null - if (owningSession != null) - { - MiddlewareUserID = owningSession.GetMiddlewareID(); - } - else - { - MiddlewareUserID = String.Empty; - } + return m_Ready; + } + set { m_Ready = value; } + } + + public UInt16 Port { get; private set; } = 0; + + public void UpdateSlotIndex(UInt16 index) + { + SlotIndex = index; + } + public int Side { get; private set; } = 0; + public int Team { get; private set; } = -1; + public int Color { get; private set; } = 0; + public int StartingPosition { get; private set; } = 0; + public bool HasMap { get; private set; } = false; + + public EPlayerType SlotState { get; private set; } = 0; + public UInt16 SlotIndex { get; private set; } = 0; + public string Region { get; private set; } = "Unknown"; + public string MiddlewareUserID { get; private set; } = String.Empty; + + [JsonIgnore] // cant serialize refs + private WeakReference CurrentLobby = new(null); + + [JsonIgnore] + private WeakReference PlayerSession = new(null); + + public WeakReference GetSession() + { + return PlayerSession; + } + + public LobbyMember(Lobby owningLobby, UserSession? owningSession, Int64 UserID_in, string DisplayName_in, string strUndedupedDisplayName, UInt16 Port_in, int Side_in, int Color_in, int StartingPosition_in, EPlayerType SlotState_in, UInt16 SlotIndex_in, bool bHasMap_in) + { + CurrentLobby = new WeakReference(owningLobby); + PlayerSession = new WeakReference(owningSession); + + UserID = UserID_in; + DisplayName = DisplayName_in; + DisplayNameNotDeduped = strUndedupedDisplayName; + Port = Port_in; + Side = Side_in; + Color = Color_in; + StartingPosition = StartingPosition_in; + HasMap = bHasMap_in; + SlotState = SlotState_in; + SlotIndex = SlotIndex_in; + + // default slots are created with null + if (owningSession != null) + { + MiddlewareUserID = owningSession.GetMiddlewareID(); + } + else + { + MiddlewareUserID = String.Empty; + } - IsReady = false; - Region = owningSession == null ? "Unknown" : owningSession.GetFullContinentName(); - } + IsReady = false; + Region = owningSession == null ? "Unknown" : owningSession.GetFullContinentName(); + } - public bool IsHuman() { return SlotState == EPlayerType.SLOT_PLAYER; } - public bool IsAI() { return SlotState == EPlayerType.SLOT_EASY_AI || SlotState == EPlayerType.SLOT_MED_AI || SlotState == EPlayerType.SLOT_BRUTAL_AI; } + public bool IsHuman() { return SlotState == EPlayerType.SLOT_PLAYER; } + public bool IsAI() { return SlotState == EPlayerType.SLOT_EASY_AI || SlotState == EPlayerType.SLOT_MED_AI || SlotState == EPlayerType.SLOT_BRUTAL_AI; } - private void DirtyRetransmit() - { - CurrentLobby.TryGetTarget(out Lobby? lobby); - lobby?.DirtyRetransmit(); - } + private void DirtyRetransmit() + { + CurrentLobby.TryGetTarget(out Lobby? lobby); + lobby?.DirtyRetransmit(); + } - public void SetReadyState(bool bReady) - { - IsReady = bReady; + public void SetReadyState(bool bReady) + { + IsReady = bReady; - DirtyRetransmit(); - } + DirtyRetransmit(); + } - public void SetPlayerSlotState(EPlayerType newState) - { - SlotState = newState; + public void SetPlayerSlotState(EPlayerType newState) + { + SlotState = newState; - // AI is always ready - if (IsAI()) - { - IsReady = true; - } + // AI is always ready + if (IsAI()) + { + IsReady = true; + } - DirtyRetransmit(); - } + DirtyRetransmit(); + } - public async Task UpdateSide(AppDbContext _db, int newSide, int start_pos) - { - Side = newSide; + public async Task UpdateSide(AppDbContext _db, int newSide, int start_pos) + { + Side = newSide; - await Database.Users.SetFavorite_Side(_db, UserID, newSide); + await Database.Users.SetFavorite_Side(_db, UserID, newSide); - DirtyRetransmit(); - } + DirtyRetransmit(); + } - public async Task UpdateColor(AppDbContext _db, int newColor) - { - Color = newColor; - await Database.Users.SetFavorite_Color(_db, UserID, newColor); + public async Task UpdateColor(AppDbContext _db, int newColor) + { + Color = newColor; + await Database.Users.SetFavorite_Color(_db, UserID, newColor); - DirtyRetransmit(); - } + DirtyRetransmit(); + } - public void UpdateStartPos(int startpos) - { - StartingPosition = startpos; + public void UpdateStartPos(int startpos) + { + StartingPosition = startpos; - DirtyRetransmit(); - } + DirtyRetransmit(); + } - public void UpdateTeam(int team) - { - Team = team; + public void UpdateTeam(int team) + { + Team = team; - DirtyRetransmit(); - } + DirtyRetransmit(); + } - public void UpdateHasMap(bool bHasMap) - { - HasMap = bHasMap; + public void UpdateHasMap(bool bHasMap) + { + HasMap = bHasMap; - DirtyRetransmit(); - } - } + DirtyRetransmit(); + } + } - public enum ELobbyType - { - CustomGame = 0, - QuickMatch = 1 - } + public enum ELobbyType + { + CustomGame = 0, + QuickMatch = 1 + } - public enum EKnownAnticheatID - { - NONE = -1, - GO_INTEGRATED_AC = 0, - EASY_ANTICHEAT = 9481 - } + public enum EKnownAnticheatID + { + NONE = -1, + GO_INTEGRATED_AC = 0, + EASY_ANTICHEAT = 9481 + } - public class LobbyManager - { - private ConcurrentDictionary m_dictLobbies = new(); + public class LobbyManager + { + private ConcurrentDictionary m_dictLobbies = new(); - private Int64 m_NextLobbyID = 0; + private Int64 m_NextLobbyID = 0; - private readonly IServiceProvider _services; + private readonly IServiceProvider _services; - public LobbyManager(IServiceProvider services) - { - _services = services; - } + public LobbyManager(IServiceProvider services) + { + _services = services; + } - public async Task Cleanup() - { - // Remove any lobby that has 0 members and has been around for a bit (enough time for host to join) - List lstLobbiesToRemove = new List(); - foreach (var kvPair in m_dictLobbies) - { - Lobby iterLobby = kvPair.Value; + 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(); + foreach (var kvPair in m_dictLobbies) + { + Lobby iterLobby = kvPair.Value; - TimeSpan timeSinceCreated = DateTime.UtcNow.Subtract(iterLobby.TimeCreated); - if (iterLobby.GetNumberOfHumans() == 0 && timeSinceCreated.TotalMinutes >= 1.0) - { + TimeSpan timeSinceCreated = DateTime.UtcNow.Subtract(iterLobby.TimeCreated); + if (iterLobby.GetNumberOfHumans() == 0 && timeSinceCreated.TotalMinutes >= 1.0) + { Console.WriteLine("Garbage collecting lobby {0}", iterLobby.LobbyID); - // mark for removal - lstLobbiesToRemove.Add(iterLobby); - } - } + // mark for removal + lstLobbiesToRemove.Add(iterLobby); + } + } - foreach (Lobby lobbyToRemove in lstLobbiesToRemove) - { + foreach (Lobby lobbyToRemove in lstLobbiesToRemove) + { // remove it, also commit it + update leaderboard await DeleteLobby(lobbyToRemove); } - } + } + + private async void HandleLobbyNeedsDestroyed(Lobby lobby) + { + await DeleteLobby(lobby); + } + + private Task GetRoomSuggestionsAsync(string name, Int16 currentRoom) + { + // exclude all within current room exclusion key, eg. if current room is 2v2, never suggest 1v1 or 3v3. Similarly for the Pro/No rules + var availableOptions = Constants.Rooms.Values.AsQueryable(); + + var currentExclusionKey = Constants.Rooms[currentRoom].exclusionKey; + if (currentExclusionKey != null) + availableOptions = availableOptions.Where(room => room.exclusionKey != currentExclusionKey); + + name = name.Replace(" ", "").ToLowerInvariant(); + var suggestedRooms = availableOptions + .Select(room => KeyValuePair.Create(room, FuzzySharp.Fuzz.PartialRatio(name, room.SearchableName))) + .Where(pair => pair.Value >= 80) + .GroupBy(pair => pair.Key.exclusionKey) + .SelectMany(group => group.Key == null ? group : group.OrderByDescending(pair => pair.Value).Take(1)) + .Select(pair => pair.Key.id) + .ToHashSet(); + + + suggestedRooms.Add(currentRoom); // always include current room + + return Task.FromResult(suggestedRooms.ToArray()); + } + + 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, EKnownAnticheatID anticheatID) + { + Console.WriteLine("Created lobby"); + // cant own two lobbies at once, unless in gameplay + await CleanupUserLobbiesNotStarted(owningSession.m_UserID); + + Console.WriteLine("[Source 3] User {0} Leave Any Lobby", owningSession.m_UserID); + this.LeaveAnyLobby(owningSession.m_UserID); + + int rng_seed = new Random().Next(); + + Int64 newLobbyID = m_NextLobbyID; + ++m_NextLobbyID; + + // load and apply user preferences (custom game only) + bool bLimitSuperweapons = false; + UInt32 starting_cash = default_starting_cash; + Int16[] rooms = [parentNetworkRoom]; + + if (lobbyType == ELobbyType.CustomGame) + { + 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) + { + starting_cash = (uint)lobbyPrefs.favorite_starting_money; + } - private async void HandleLobbyNeedsDestroyed(Lobby lobby) - { - await DeleteLobby(lobby); - } + // find suggested rooms + rooms = await GetRoomSuggestionsAsync(strName, parentNetworkRoom); + } - 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, EKnownAnticheatID anticheatID) - { - Console.WriteLine("Created lobby"); - // cant own two lobbies at once, unless in gameplay - await CleanupUserLobbiesNotStarted(owningSession.m_UserID); + Lobby newLobby = new Lobby(newLobbyID, owningSession, strName, ELobbyState.GAME_SETUP, strMapName, strMapPath, bVanillaTeams, starting_cash, bLimitSuperweapons, bTrackStats, bPassworded, strPassword, bMapOfficial, rng_seed, rooms, bAllowObservers, maxCamHeight, exe_crc, ini_crc, maxPlayers, lobbyType, anticheatID); + m_dictLobbies[newLobbyID] = newLobby; - Console.WriteLine("[Source 3] User {0} Leave Any Lobby", owningSession.m_UserID); - this.LeaveAnyLobby(owningSession.m_UserID); + // subscribe for self-destruct event + newLobby.OnLobbyNeedsDestroyed += HandleLobbyNeedsDestroyed; - int rng_seed = new Random().Next(); + // 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(_db, newLobby, owningSession, strOwnerDisplayName, hostPreferredPort, true); + } - Int64 newLobbyID = m_NextLobbyID; - ++m_NextLobbyID; + newLobby.DirtyRetransmit(); - // load and apply user preferences (custom game only) - bool bLimitSuperweapons = false; - UInt32 starting_cash = default_starting_cash; - if (lobbyType == ELobbyType.CustomGame) - { - 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) - { - starting_cash = (uint)lobbyPrefs.favorite_starting_money; - } - } + // inform + await WebSocketManager.SendNewOrDeletedLobbyToAllNetworkRoomMembers(parentNetworkRoom); - 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, anticheatID); - m_dictLobbies[newLobbyID] = newLobby; + return newLobbyID; + } - + public async Task Tick() + { + foreach (var kvPair in m_dictLobbies) + { + await kvPair.Value.Tick(); + } + } - // subscribe for self-destruct event - newLobby.OnLobbyNeedsDestroyed += HandleLobbyNeedsDestroyed; + public async Task JoinLobby(AppDbContext _db, Lobby lobby, UserSession playerSession, string strDisplayName, UInt16 userPreferredPort, bool bHasMap) + { + UserLobbyPreferences? lobbyPrefs = await Database.Users.GetUserLobbyPreferences(_db, playerSession.m_UserID); - // 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(_db, newLobby, owningSession, strOwnerDisplayName, hostPreferredPort, true); - } + if (lobbyPrefs != null) + { + bool bAdded = await lobby.AddMember(playerSession, strDisplayName, userPreferredPort, bHasMap, lobbyPrefs); + return bAdded; + } - newLobby.DirtyRetransmit(); + return false; + } + + public int GetNumLobbies() + { + return m_dictLobbies.Count; + } + + public async Task CleanupUserLobbiesNotStarted(Int64 UserID) + { + List ownedLobbies = GetPlayerOwnedLobbies(UserID); + foreach (Lobby ownedLobby in ownedLobbies) + { + if (ownedLobby.State == ELobbyState.GAME_SETUP) // only those in setup, in game games can continue + { + await DeleteLobby(ownedLobby); + } + } + } + + 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) + { + Lobby lobby = kvp.Value; + if (!bIncludeAllNetworkRooms && !lobby.NetworkRoomIDs.Contains(networkRoomID)) + { + continue; + } - // inform - await WebSocketManager.SendNewOrDeletedLobbyToAllNetworkRoomMembers(parentNetworkRoom); + bool bMeetsCriteria = true; + if (lobby.IsPassworded && !bIncludePassword) + { + bMeetsCriteria = false; + } - return newLobbyID; - } + // don't allow quickmatch to show + if (lobby.LobbyType == ELobbyType.QuickMatch) + { + bMeetsCriteria = false; + } - public async Task Tick() - { - foreach (var kvPair in m_dictLobbies) - { - await kvPair.Value.Tick(); - } - } + if (lobby.State == ELobbyState.GAME_SETUP && !bAllowInSetup) + { + bMeetsCriteria = false; + } - public async Task JoinLobby(AppDbContext _db, Lobby lobby, UserSession playerSession, string strDisplayName, UInt16 userPreferredPort, bool bHasMap) - { - UserLobbyPreferences? lobbyPrefs = await Database.Users.GetUserLobbyPreferences(_db, playerSession.m_UserID); + if (lobby.State == ELobbyState.INGAME && !bAllowInGame) + { + bMeetsCriteria = false; + } - if (lobbyPrefs != null) - { - bool bAdded = await lobby.AddMember(playerSession, strDisplayName, userPreferredPort, bHasMap, lobbyPrefs); - return bAdded; - } + if (lobby.State == ELobbyState.COMPLETE && !bAllowCompleted) + { + bMeetsCriteria = false; + } - return false; - } + if (bMeetsCriteria) + { + listLobbies.Add(lobby); + } + } - public int GetNumLobbies() - { - return m_dictLobbies.Count; - } + return listLobbies; + } - public async Task CleanupUserLobbiesNotStarted(Int64 UserID) - { - List ownedLobbies = GetPlayerOwnedLobbies(UserID); - foreach (Lobby ownedLobby in ownedLobbies) - { - if (ownedLobby.State == ELobbyState.GAME_SETUP) // only those in setup, in game games can continue - { - await DeleteLobby(ownedLobby); - } - } - } + public Lobby? GetLobby(Int64 lobbyID) + { + if (m_dictLobbies.TryGetValue(lobbyID, out Lobby? lobby)) + { + return lobby; + } - 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) - { - Lobby lobby = kvp.Value; - if (!bIncludeAllNetworkRooms && lobby.NetworkRoomID != networkRoomID) - { - continue; - } - - bool bMeetsCriteria = true; - if (lobby.IsPassworded && !bIncludePassword) - { - bMeetsCriteria = false; - } - - // don't allow quickmatch to show - if (lobby.LobbyType == ELobbyType.QuickMatch) - { - bMeetsCriteria = false; - } - - if (lobby.State == ELobbyState.GAME_SETUP && !bAllowInSetup) - { - bMeetsCriteria = false; - } - - if (lobby.State == ELobbyState.INGAME && !bAllowInGame) - { - bMeetsCriteria = false; - } - - if (lobby.State == ELobbyState.COMPLETE && !bAllowCompleted) - { - bMeetsCriteria = false; - } - - if (bMeetsCriteria) - { - listLobbies.Add(lobby); - } - } + return null; + } - return listLobbies; - } + public Lobby? GetLobbyFiltered(Int64 lobbyID, bool bIncludePassword, bool bAllowInSetup, bool bAllowInGame, bool bAllowCompleted) + { + if (m_dictLobbies.TryGetValue(lobbyID, out Lobby? lobby)) + { + bool bMeetsCriteria = true; - public Lobby? GetLobby(Int64 lobbyID) - { - if (m_dictLobbies.TryGetValue(lobbyID, out Lobby? lobby)) - { - return lobby; - } + if (lobby.IsPassworded && !bIncludePassword) + { + bMeetsCriteria = false; + } - return null; - } + if (lobby.State == ELobbyState.GAME_SETUP && !bAllowInSetup) + { + bMeetsCriteria = false; + } - public Lobby? GetLobbyFiltered(Int64 lobbyID, bool bIncludePassword, bool bAllowInSetup, bool bAllowInGame, bool bAllowCompleted) - { - if (m_dictLobbies.TryGetValue(lobbyID, out Lobby? lobby)) - { - bool bMeetsCriteria = true; - - if (lobby.IsPassworded && !bIncludePassword) - { - bMeetsCriteria = false; - } - - if (lobby.State == ELobbyState.GAME_SETUP && !bAllowInSetup) - { - bMeetsCriteria = false; - } - - if (lobby.State == ELobbyState.INGAME && !bAllowInGame) - { - bMeetsCriteria = false; - } - - if (lobby.State == ELobbyState.COMPLETE && !bAllowCompleted) - { - bMeetsCriteria = false; - } - - if (bMeetsCriteria) - { - return lobby; - } - else - { - return null; - } - } - return null; - } + if (lobby.State == ELobbyState.INGAME && !bAllowInGame) + { + bMeetsCriteria = false; + } - public Lobby? GetPlayerParticipantLobby(Int64 userID) - { - // TODO_LOBBY: Optimize this, maintain a dictionary of userid - foreach (Lobby lobbyInst in m_dictLobbies.Values) - { - if (lobbyInst.GetMemberFromUserID(userID) != null) - { - return lobbyInst; - } - } + if (lobby.State == ELobbyState.COMPLETE && !bAllowCompleted) + { + bMeetsCriteria = false; + } - return null; - } + if (bMeetsCriteria) + { + return lobby; + } + else + { + return null; + } + } + return null; + } + + public Lobby? GetPlayerParticipantLobby(Int64 userID) + { + // TODO_LOBBY: Optimize this, maintain a dictionary of userid + foreach (Lobby lobbyInst in m_dictLobbies.Values) + { + if (lobbyInst.GetMemberFromUserID(userID) != null) + { + return lobbyInst; + } + } - 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 - List lstLobbies = new List(); - foreach (Lobby lobbyInst in m_dictLobbies.Values) - { - if (lobbyInst.Owner == userID) - { - lstLobbies.Add(lobbyInst); - } - } + return null; + } + + 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 + List lstLobbies = new List(); + foreach (Lobby lobbyInst in m_dictLobbies.Values) + { + if (lobbyInst.Owner == userID) + { + lstLobbies.Add(lobbyInst); + } + } + + return lstLobbies; + } + + public async Task LeaveSpecificLobby(Int64 userID, Int64 lobbyID) + { + Lobby? targetLobby = GetLobby(lobbyID); + if (targetLobby != null) + { + LobbyMember? memberEntry = targetLobby.GetMemberFromUserID(userID); + if (memberEntry != null) + { + Console.WriteLine("User {0} Leave Specific Lobby", userID); + await targetLobby.RemoveMember(memberEntry); + } + } + } + + public async Task LeaveAnyLobby(Int64 userID) + { + foreach (Lobby lobbyInst in m_dictLobbies.Values) + { + LobbyMember? member = lobbyInst.GetMemberFromUserID(userID); + if (member != null) + { + Console.WriteLine("User {0} Leave Any Lobby", userID); + await lobbyInst.RemoveMember(member); + } + } + } + + public async Task DeleteLobby(Lobby lobby) + { + try + { + 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.MatchHistory.CommitLobbyToMatchHistory(db, lobby); + } - return lstLobbies; - } + // delete + bool bRemoved = m_dictLobbies.Remove(lobby.LobbyID, out _); + foreach (var roomID in lobby.NetworkRoomIDs) + await WebSocketManager.SendNewOrDeletedLobbyToAllNetworkRoomMembers(roomID); - public async Task LeaveSpecificLobby(Int64 userID, Int64 lobbyID) - { - Lobby? targetLobby = GetLobby(lobbyID); - if (targetLobby != null) - { - LobbyMember? memberEntry = targetLobby.GetMemberFromUserID(userID); - if (memberEntry != null) - { - Console.WriteLine("User {0} Leave Specific Lobby", userID); - await targetLobby.RemoveMember(memberEntry); - } - } - } - public async Task LeaveAnyLobby(Int64 userID) - { - foreach (Lobby lobbyInst in m_dictLobbies.Values) - { - LobbyMember? member = lobbyInst.GetMemberFromUserID(userID); - if (member != null) - { - Console.WriteLine("User {0} Leave Any Lobby", userID); - await lobbyInst.RemoveMember(member); - } - } - } + // only do this once + if (bRemoved) + { + // unsubscribe from self-destruct event + lobby.OnLobbyNeedsDestroyed -= HandleLobbyNeedsDestroyed; - public async Task DeleteLobby(Lobby lobby) - { - try - { - 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.MatchHistory.CommitLobbyToMatchHistory(db, lobby); - } - - // delete - bool bRemoved = m_dictLobbies.Remove(lobby.LobbyID, out _); - await WebSocketManager.SendNewOrDeletedLobbyToAllNetworkRoomMembers(lobby.NetworkRoomID); - - // only do this once - if (bRemoved) - { - // unsubscribe from self-destruct event - lobby.OnLobbyNeedsDestroyed -= HandleLobbyNeedsDestroyed; - - // make sure we have a winner - await Database.MatchHistory.DetermineLobbyWinnerIfNotPresent(db, lobby); - - // if its a quickmatch, update our leaderboards - if (lobby.LobbyType == ELobbyType.QuickMatch) - { - await Database.MatchHistory.UpdateLeaderboardAndElo(db, lobby); - } - } - - return bRemoved; - } - catch (Exception ex) - { - Console.WriteLine($"[ERROR] DeleteLobby failed: {ex.Message}"); - SentrySdk.CaptureException(ex); - return false; - } - } - - public bool IsUserInLobby(Lobby lobby, Int64 user_id) - { - LobbyMember? member = lobby.GetMemberFromUserID(user_id); - return member != null; - } - } + // make sure we have a winner + await Database.MatchHistory.DetermineLobbyWinnerIfNotPresent(db, lobby); + + // if its a quickmatch, update our leaderboards + if (lobby.LobbyType == ELobbyType.QuickMatch) + { + await Database.MatchHistory.UpdateLeaderboardAndElo(db, lobby); + } + } + + return bRemoved; + } + catch (Exception ex) + { + Console.WriteLine($"[ERROR] DeleteLobby failed: {ex.Message}"); + SentrySdk.CaptureException(ex); + return false; + } + } + + public bool IsUserInLobby(Lobby lobby, Int64 user_id) + { + LobbyMember? member = lobby.GetMemberFromUserID(user_id); + return member != null; + } + } } \ No newline at end of file diff --git a/GenOnlineService/data/rooms.json b/GenOnlineService/data/rooms.json index 4876aa1..bbe6cc6 100644 --- a/GenOnlineService/data/rooms.json +++ b/GenOnlineService/data/rooms.json @@ -2,7 +2,7 @@ { "id": 0, "name": "ALL GAMES", - "flags": 1 + "flags": 1 }, { "id": 1, @@ -12,26 +12,43 @@ { "id": 2, "name": "1v1", - "flags": 0 + "flags": 0, + "exclusionKey": "nvn" }, { "id": 3, "name": "2v2", - "flags": 0 + "flags": 0, + "exclusionKey": "nvn" }, { "id": 4, "name": "No Rules", - "flags": 0 + "flags": 0, + "exclusionKey": "rules" }, { "id": 5, "name": "Pro Rules", - "flags": 0 + "flags": 0, + "exclusionKey": "rules" }, { "id": 6, "name": "Rise of the Reds (MOD)", "flags": 0 + }, + { + "id": 7, + "name": "3v3", + "flags": 0, + "exclusionKey": "nvn" + + }, + { + "id": 8, + "name": "4v4", + "flags": 0, + "exclusionKey": "nvn" } ] \ No newline at end of file