diff --git a/Projects/Server/Main.cs b/Projects/Server/Main.cs index 1eabf25fc..73336ba70 100644 --- a/Projects/Server/Main.cs +++ b/Projects/Server/Main.cs @@ -50,6 +50,7 @@ public static class Core private static int _itemCount; private static int _mobileCount; public static EventLoopContext LoopContext { get; set; } + public static TaskScheduler LoopContextTaskScheduler { get; set; } private static readonly Type[] _serialTypeArray = { typeof(Serial) }; @@ -372,6 +373,7 @@ public static class Core Thread = Thread.CurrentThread; LoopContext = new EventLoopContext(); SynchronizationContext.SetSynchronizationContext(LoopContext); + LoopContextTaskScheduler = TaskScheduler.FromCurrentSynchronizationContext(); AppDomain.CurrentDomain.UnhandledException += CurrentDomain_UnhandledException; AppDomain.CurrentDomain.ProcessExit += CurrentDomain_ProcessExit; diff --git a/Projects/Server/Tasks/TaskExtensions.cs b/Projects/Server/Tasks/TaskExtensions.cs new file mode 100644 index 000000000..76686d9e9 --- /dev/null +++ b/Projects/Server/Tasks/TaskExtensions.cs @@ -0,0 +1,48 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace Server.Tasks; + +public static class TaskExtensions +{ + public static void OnFaultedCurrentSyncContext(this Task task, Action onFaulted, CancellationToken cancellationToken = default) + { + var scheduler = TaskScheduler.FromCurrentSynchronizationContext(); + task.ContinueWith(onFaulted, cancellationToken, TaskContinuationOptions.OnlyOnFaulted, scheduler); + } + + public static void ContinueWithOnCurrentSyncContext( + this Task task, + Action> onRanToCompletion, + Action> onFaulted, + CancellationToken cancellationToken = default + ) + { + var scheduler = TaskScheduler.FromCurrentSynchronizationContext(); + task.ContinueWith(onRanToCompletion, cancellationToken, TaskContinuationOptions.OnlyOnRanToCompletion, scheduler); + task.ContinueWith(onFaulted, cancellationToken, TaskContinuationOptions.OnlyOnFaulted, scheduler); + } + + public static void ContinueWithOnCurrentSyncContext( + this Task task, + Action> onCompletion, + CancellationToken cancellationToken = default + ) + { + var scheduler = TaskScheduler.FromCurrentSynchronizationContext(); + task.ContinueWith(onCompletion, cancellationToken, TaskContinuationOptions.None, scheduler); + } + + public static void ContinueWithOnGameThread( + this Task task, + Action> onCompletion, + CancellationToken cancellationToken = default + ) => task.ContinueWith(onCompletion, cancellationToken, TaskContinuationOptions.None, Core.LoopContextTaskScheduler); + + public static void ContinueWithOnGameThread( + this Task task, + Action onCompletion, + CancellationToken cancellationToken = default + ) => task.ContinueWith(onCompletion, cancellationToken, TaskContinuationOptions.None, Core.LoopContextTaskScheduler); +} diff --git a/Projects/UOContent/Commands/MovementDebugCommands.cs b/Projects/UOContent/Commands/MovementDebugCommands.cs index 090ada367..90dbb0596 100644 --- a/Projects/UOContent/Commands/MovementDebugCommands.cs +++ b/Projects/UOContent/Commands/MovementDebugCommands.cs @@ -1,4 +1,3 @@ -using Server.Commands; using Server.Network; using Server.Targeting; diff --git a/Projects/UOContent/Engines/Player Murder System/BountyReportMurdererGump.cs b/Projects/UOContent/Engines/Player Murder System/BountyReportMurdererGump.cs index c099fd2b9..2f68a22e8 100644 --- a/Projects/UOContent/Engines/Player Murder System/BountyReportMurdererGump.cs +++ b/Projects/UOContent/Engines/Player Murder System/BountyReportMurdererGump.cs @@ -1,7 +1,6 @@ using System; using System.Collections.Generic; using Server.Gumps; -using Server.Items; using Server.Mobiles; using Server.Network; diff --git a/Projects/UOContent/Network/GameServer.cs b/Projects/UOContent/Network/GameServer.cs index 9fc170d69..e8fc075ac 100644 --- a/Projects/UOContent/Network/GameServer.cs +++ b/Projects/UOContent/Network/GameServer.cs @@ -21,6 +21,13 @@ public static partial class GameServer public bool Accepted { get; set; } + /// + /// When true, the login will be completed asynchronously (e.g., by a gateway handler). + /// The packet handler skips both the accept and reject paths. + /// The handler that sets this is responsible for calling SendCharacterList or Disconnect. + /// + public bool Deferred { get; set; } + public CityInfo[] CityInfo { get; set; } } diff --git a/Projects/UOContent/Network/Packets/IncomingAccountPackets.cs b/Projects/UOContent/Network/Packets/IncomingAccountPackets.cs index 07a444d03..c9b9f71ea 100644 --- a/Projects/UOContent/Network/Packets/IncomingAccountPackets.cs +++ b/Projects/UOContent/Network/Packets/IncomingAccountPackets.cs @@ -384,6 +384,13 @@ public static class IncomingAccountPackets GameServer.GameServerLoginEvent(e); + if (e.Deferred) + { + // Login is being handled asynchronously (e.g., gateway validation). + // The handler that set Deferred is responsible for completing the login. + return; + } + if (e.Accepted) { state.CityInfo = e.CityInfo; diff --git a/Projects/UOContent/Systems/Gateway/GatewayAdminHandler.cs b/Projects/UOContent/Systems/Gateway/GatewayAdminHandler.cs new file mode 100644 index 000000000..a9245af44 --- /dev/null +++ b/Projects/UOContent/Systems/Gateway/GatewayAdminHandler.cs @@ -0,0 +1,48 @@ +using Server.Logging; +using Server.Network; + +namespace Server.Systems.Gateway; + +/// +/// Handles admin commands pushed from the gateway web portal via SignalR. +/// All methods run on the game thread (marshaled by GatewayClient callback handlers). +/// +public static class GatewayAdminHandler +{ + private static readonly ILogger logger = LogFactory.GetLogger(typeof(GatewayAdminHandler)); + + public static void HandleCommand(GatewayClient.AdminCommandData command) + { + logger.Information("Received admin command: {Type}", command.Type); + + // Dispatch based on command type + // For now, just log. Specific handlers will be implemented as features are built. + switch (command.Type.ToLowerInvariant()) + { + case "banaccount": + // TODO: Find online player, disconnect them, mark account banned + logger.Information("Ban account command received: {Payload}", command.Payload); + break; + case "kickplayer": + // TODO: Find online player by name/serial, disconnect + logger.Information("Kick player command received: {Payload}", command.Payload); + break; + case "broadcastmessage": + // Broadcast to all online players + foreach (var ns in NetState.Instances) + { + ns.Mobile?.SendMessage(command.Payload); + } + + logger.Information("Broadcast message sent: {Payload}", command.Payload); + break; + case "gmpage": + // TODO: Route to online GM + logger.Information("GM page received: {Payload}", command.Payload); + break; + default: + logger.Warning("Unknown admin command type: {Type}", command.Type); + break; + } + } +} diff --git a/Projects/UOContent/Systems/Gateway/GatewayClient.cs b/Projects/UOContent/Systems/Gateway/GatewayClient.cs new file mode 100644 index 000000000..8fc524d10 --- /dev/null +++ b/Projects/UOContent/Systems/Gateway/GatewayClient.cs @@ -0,0 +1,176 @@ +using System; +using System.Net.Http; +using System.Net.Http.Json; +using System.Text.Json.Serialization; +using System.Threading.Tasks; +using Microsoft.AspNetCore.SignalR.Client; +using Server.Logging; + +namespace Server.Systems.Gateway; + +public static class GatewayClient +{ + private static readonly ILogger logger = LogFactory.GetLogger(typeof(GatewayClient)); + + // REST client (always available as fallback) + private static HttpClient _httpClient; + + // SignalR client (optional, preferred when connected) + private static HubConnection _hubConnection; + + public static bool IsReady => _httpClient != null; + public static bool IsSignalRConnected => _hubConnection?.State == HubConnectionState.Connected; + + public static void Configure() + { + if (!GatewayConfig.Enabled) + { + return; + } + + // REST client (always configured) + _httpClient = new HttpClient + { + BaseAddress = new Uri(GatewayConfig.GatewayUrl.TrimEnd('/')), + Timeout = TimeSpan.FromSeconds(10) + }; + _httpClient.DefaultRequestHeaders.Add("Authorization", $"ApiKey {GatewayConfig.ApiKey}"); + + // SignalR client (optional) + if (GatewayConfig.SignalREnabled) + { + var hubUrl = $"{GatewayConfig.GatewayUrl.TrimEnd('/')}/hubs/gameserver?apiKey={GatewayConfig.ApiKey}"; + + _hubConnection = new HubConnectionBuilder() + .WithUrl(hubUrl) + .WithAutomaticReconnect(new[] + { + TimeSpan.Zero, + TimeSpan.FromSeconds(2), + TimeSpan.FromSeconds(5), + TimeSpan.FromSeconds(10), + TimeSpan.FromSeconds(30) + }) + .Build(); + + // Register handlers -- all marshal to game thread + _hubConnection.On("PushSession", session => + { + Core.LoopContext.Post(() => GatewaySessionStore.Add(session), EventLoopContext.Priority.High); + }); + + _hubConnection.On("AdminCommand", command => + { + Core.LoopContext.Post(() => GatewayAdminHandler.HandleCommand(command)); + }); + + _hubConnection.On("Ping", () => + { + // No-op, connection keep-alive handled by SignalR internally + }); + + _hubConnection.Reconnected += connectionId => + { + logger.Information("SignalR reconnected to gateway (ConnectionId: {Id})", connectionId); + return Task.CompletedTask; + }; + + _hubConnection.Closed += error => + { + logger.Warning("SignalR connection to gateway closed: {Message}", error?.Message ?? "clean disconnect"); + return Task.CompletedTask; + }; + + // Connect after server starts (via EventSink.ServerStarted) + } + } + + /// + /// Connects the SignalR hub. Called after server is fully loaded. + /// + public static async Task ConnectSignalRAsync() + { + if (_hubConnection == null) + { + return; + } + + try + { + await _hubConnection.StartAsync(); + logger.Information("SignalR connected to gateway at {Url}", GatewayConfig.GatewayUrl); + } + catch (Exception ex) + { + logger.Warning("SignalR connection to gateway failed: {Message}. Will retry automatically.", ex.Message); + } + } + + // --- SignalR methods --- + + public static Task SendHeartbeatAsync(HeartbeatRequest data) => + _hubConnection?.InvokeAsync("Heartbeat", new { data.PlayerCount, data.MaxPlayers, data.IsOnline }) + ?? Task.CompletedTask; + + public static async Task ValidateSessionAsync(int authId) + { + if (_hubConnection?.State != HubConnectionState.Connected) + { + return null; + } + + try + { + var result = await _hubConnection.InvokeAsync("ValidateSession", authId); + if (result == null) + { + return null; + } + + return new GameLoginResponse(result.Valid, result.Reason, result.GameAccountId, result.AccessLevel); + } + catch (Exception ex) + { + logger.Warning("SignalR ValidateSession failed: {Message}", ex.Message); + return null; + } + } + + // --- REST methods (fallback) --- + + public static Task PostAsJsonAsync(string path, T content) => + _httpClient.PostAsJsonAsync(path, content); + + // --- Types --- + + public record HeartbeatRequest( + [property: JsonPropertyName("playerCount")] int PlayerCount, + [property: JsonPropertyName("maxPlayers")] int MaxPlayers, + [property: JsonPropertyName("isOnline")] bool IsOnline + ); + + public record GameLoginRequest( + [property: JsonPropertyName("authId")] int AuthId, + [property: JsonPropertyName("username")] string Username, + [property: JsonPropertyName("password")] string Password + ); + + public record GameLoginResponse( + [property: JsonPropertyName("accepted")] bool Accepted, + [property: JsonPropertyName("reason")] string? Reason, + [property: JsonPropertyName("accountId")] Guid? AccountId, + [property: JsonPropertyName("accessLevel")] string? AccessLevel + ); + + public record AdminCommandData( + [property: JsonPropertyName("type")] string Type, + [property: JsonPropertyName("payload")] string Payload + ); + + public record SessionValidationResult( + [property: JsonPropertyName("valid")] bool Valid, + [property: JsonPropertyName("gameAccountId")] Guid? GameAccountId, + [property: JsonPropertyName("accessLevel")] string? AccessLevel, + [property: JsonPropertyName("reason")] string? Reason + ); +} diff --git a/Projects/UOContent/Systems/Gateway/GatewayConfig.cs b/Projects/UOContent/Systems/Gateway/GatewayConfig.cs new file mode 100644 index 000000000..b6d2231f8 --- /dev/null +++ b/Projects/UOContent/Systems/Gateway/GatewayConfig.cs @@ -0,0 +1,45 @@ +using Server.Logging; + +namespace Server.Systems.Gateway; + +/// +/// Configuration for the ModernUO Gateway integration. +/// When enabled, game server login (0x91) is validated against the Gateway API +/// and heartbeats are sent periodically. +/// +/// Settings are stored in modernuo.json under the "gateway" prefix. +/// +public static class GatewayConfig +{ + private static readonly ILogger logger = LogFactory.GetLogger(typeof(GatewayConfig)); + + public static bool Enabled { get; private set; } + public static string GatewayUrl { get; private set; } = ""; + public static string ApiKey { get; private set; } = ""; + public static int HeartbeatIntervalSeconds { get; private set; } + public static int MaxPlayers { get; private set; } + public static bool SignalREnabled { get; private set; } + + public static void Configure() + { + Enabled = ServerConfiguration.GetOrUpdateSetting("gateway.enabled", false); + + if (!Enabled) + { + return; + } + + GatewayUrl = ServerConfiguration.GetOrUpdateSetting("gateway.url", "http://localhost:5000"); + ApiKey = ServerConfiguration.GetOrUpdateSetting("gateway.apiKey", ""); + HeartbeatIntervalSeconds = ServerConfiguration.GetOrUpdateSetting("gateway.heartbeatIntervalSeconds", 30); + MaxPlayers = ServerConfiguration.GetOrUpdateSetting("gateway.maxPlayers", 200); + SignalREnabled = ServerConfiguration.GetOrUpdateSetting("gateway.signalREnabled", false); + + if (string.IsNullOrWhiteSpace(ApiKey)) + { + logger.Warning("Gateway is enabled but gateway.apiKey is empty. Heartbeats and login validation will fail."); + } + + logger.Information("Gateway integration enabled: {Url}", GatewayUrl); + } +} diff --git a/Projects/UOContent/Systems/Gateway/GatewayHeartbeat.cs b/Projects/UOContent/Systems/Gateway/GatewayHeartbeat.cs new file mode 100644 index 000000000..5c0020bcc --- /dev/null +++ b/Projects/UOContent/Systems/Gateway/GatewayHeartbeat.cs @@ -0,0 +1,79 @@ +using System; +using Server.Logging; +using Server.Network; + +namespace Server.Systems.Gateway; + +/// +/// Sends periodic heartbeats to the ModernUO Gateway so it knows this server is online. +/// Reports player count and online status. +/// Prefers SignalR when connected, falls back to REST. +/// +public static class GatewayHeartbeat +{ + private static readonly ILogger logger = LogFactory.GetLogger(typeof(GatewayHeartbeat)); + + private static TimerExecutionToken _heartbeatTimer; + + public static void Configure() + { + if (!GatewayConfig.Enabled) + { + return; + } + + EventSink.ServerStarted += OnServerStarted; + } + + private static void OnServerStarted() + { + logger.Information( + "Gateway heartbeat started, sending every {Interval}s", + GatewayConfig.HeartbeatIntervalSeconds); + + var interval = TimeSpan.FromSeconds(GatewayConfig.HeartbeatIntervalSeconds); + Timer.StartTimer(interval, interval, SendHeartbeat, out _heartbeatTimer); + + // Send first heartbeat immediately + SendHeartbeat(); + + // Connect SignalR if enabled + if (GatewayConfig.SignalREnabled) + { + _ = GatewayClient.ConnectSignalRAsync(); + } + } + + private static async void SendHeartbeat() + { + if (!GatewayClient.IsReady) + { + return; + } + + try + { + var playerCount = NetState.Instances.Count; + var request = new GatewayClient.HeartbeatRequest(playerCount, GatewayConfig.MaxPlayers, true); + + if (GatewayClient.IsSignalRConnected) + { + await GatewayClient.SendHeartbeatAsync(request); + } + else + { + // REST fallback + var response = await GatewayClient.PostAsJsonAsync("/api/heartbeat", request); + + if (!response.IsSuccessStatusCode) + { + logger.Warning("Gateway heartbeat failed: {StatusCode}", (int)response.StatusCode); + } + } + } + catch (Exception ex) + { + logger.Warning("Gateway heartbeat error: {Message}", ex.Message); + } + } +} diff --git a/Projects/UOContent/Systems/Gateway/GatewayLoginHandler.cs b/Projects/UOContent/Systems/Gateway/GatewayLoginHandler.cs new file mode 100644 index 000000000..3884e1a02 --- /dev/null +++ b/Projects/UOContent/Systems/Gateway/GatewayLoginHandler.cs @@ -0,0 +1,188 @@ +using System; +using System.Net.Http.Json; +using System.Threading.Tasks; +using ModernUO.CodeGeneratedEvents; +using Server.Accounting; +using Server.Engines.CharacterCreation; +using Server.Logging; +using Server.Network; +using Server.Tasks; + +namespace Server.Systems.Gateway; + +/// +/// Intercepts the game server login event (0x91 packet) and validates against the Gateway API +/// instead of using local account storage. +/// +/// Uses a three-tier approach: +/// 1. Fast path: check local pushed session store (O(1), no network) -- sessions pushed via SignalR +/// 2. SignalR path: validate session over SignalR hub connection +/// 3. REST path: fallback HTTP validation +/// +/// When gateway.enabled is false, this handler returns immediately and the default +/// AccountHandler processes the login as normal. +/// +public static class GatewayLoginHandler +{ + private static readonly ILogger logger = LogFactory.GetLogger(typeof(GatewayLoginHandler)); + + [OnEvent(nameof(GameServer.GameServerLoginEvent))] + public static void OnGameServerLogin(GameServer.GameLoginEventArgs e) + { + if (!GatewayConfig.Enabled || !GatewayClient.IsReady) + { + return; + } + + var state = e.State; + var username = e.Username; + var authId = state.AuthId; + + // Fast path: check local pushed session store (O(1), no network) + if (GatewaySessionStore.TryConsume(authId, out var session)) + { + logger.Information("Login: {NetState} Account '{Username}' accepted via pushed session", state, username); + AcceptLogin(e, state, username, session.AccessLevel); + return; + } + + // Slow path: network validation (SignalR or REST) + var password = e.Password; + + // Defer the login -- tells the packet handler to skip both accept and reject paths. + e.Deferred = true; + + // HTTP call on background thread, result posted back to game thread. + Task.Run(() => ValidateViaNetworkAsync(authId, username, password)) + .ContinueWithOnGameThread(t => OnValidationComplete(t, state, username)); + } + + private static async Task ValidateViaNetworkAsync( + int authId, + string username, + string password) + { + // Try SignalR first + if (GatewayClient.IsSignalRConnected) + { + var signalRResult = await GatewayClient.ValidateSessionAsync(authId); + if (signalRResult != null) + { + return signalRResult; + } + } + + // Fall back to REST + var request = new GatewayClient.GameLoginRequest(authId, username, password); + var httpResponse = await GatewayClient.PostAsJsonAsync("/api/validate-game-login", request); + httpResponse.EnsureSuccessStatusCode(); + return await httpResponse.Content.ReadFromJsonAsync(); + } + + /// + /// Runs on the game thread after the background network call completes. + /// + private static void OnValidationComplete(Task task, NetState state, string username) + { + if (!state.Running) + { + return; // Client disconnected while we were validating + } + + if (task.IsFaulted) + { + logger.Error(task.Exception, "Gateway login validation failed for '{Username}'", username); + state.Disconnect($"Gateway validation error."); + return; + } + + var response = task.Result; + + if (response is not { Accepted: true }) + { + logger.Information( + "Login: {NetState} Gateway rejected '{Username}': {Reason}", + state, username, response?.Reason ?? "unknown"); + state.Disconnect($"Gateway rejected login: {response?.Reason ?? "unknown"}"); + return; + } + + // Gateway accepted -- use common accept logic (deferred path) + logger.Information("Login: {NetState} Account '{Username}' at character list (via Gateway)", state, username); + AcceptLoginDeferred(state, username, response.AccessLevel); + } + + /// + /// Handles login acceptance for the fast path (non-deferred, from pushed session store). + /// Sets event args so the packet handler sends the response. + /// + private static void AcceptLogin(GameServer.GameLoginEventArgs e, NetState state, string username, string accessLevel) + { + var acct = FindOrCreateAccount(state, username, accessLevel); + + if (acct.Banned) + { + logger.Information("Login: {NetState} Locally banned account '{Username}'", state, username); + e.Accepted = false; + return; + } + + acct.LogAccess(state); + + state.Account = acct; + state.CityInfo = CharacterCreation.GetStartingCities(); + e.Accepted = true; + e.CityInfo = CharacterCreation.GetStartingCities(); + } + + /// + /// Handles login acceptance for the deferred path (after async network validation). + /// Sends packets directly since the original packet handler has already returned. + /// + private static void AcceptLoginDeferred(NetState state, string username, string accessLevel) + { + var acct = FindOrCreateAccount(state, username, accessLevel); + + if (acct.Banned) + { + logger.Information("Login: {NetState} Locally banned account '{Username}'", state, username); + state.Disconnect("Account is locally banned."); + return; + } + + acct.LogAccess(state); + + state.Account = acct; + state.CityInfo = CharacterCreation.GetStartingCities(); + + // Send the same packets that IncomingAccountPackets.GameLogin sends on e.Accepted = true + state.CompressionEnabled = true; + state.SendSupportedFeature(); + state.SendCharacterList(); + } + + /// + /// Finds an existing local account or creates a new shell account. + /// Gateway owns credentials; local account is for character storage only. + /// + private static Account FindOrCreateAccount(NetState state, string username, string accessLevel) + { + var acct = Accounts.GetAccount(username) as Account; + + if (acct == null) + { + acct = new Account(username, Guid.NewGuid().ToString()); + + if (Enum.TryParse(accessLevel, true, out var level)) + { + acct.AccessLevel = level; + } + + logger.Information( + "Login: {NetState} Created local account for '{Username}' (AccessLevel: {Level})", + state, username, acct.AccessLevel); + } + + return acct; + } +} diff --git a/Projects/UOContent/Systems/Gateway/GatewaySessionStore.cs b/Projects/UOContent/Systems/Gateway/GatewaySessionStore.cs new file mode 100644 index 000000000..251080cc7 --- /dev/null +++ b/Projects/UOContent/Systems/Gateway/GatewaySessionStore.cs @@ -0,0 +1,91 @@ +using System; +using System.Collections.Generic; +using Server.Logging; + +namespace Server.Systems.Gateway; + +/// +/// Stores sessions pushed from the gateway via SignalR. +/// Accessed only on the game thread -- no concurrency needed. +/// When a client sends 0x91, the login handler checks here first (O(1) lookup) +/// before falling back to network validation. +/// +public static class GatewaySessionStore +{ + private static readonly ILogger logger = LogFactory.GetLogger(typeof(GatewaySessionStore)); + private static readonly Dictionary _sessions = new(); + private static TimerExecutionToken _cleanupTimer; + + public static void Configure() + { + if (!GatewayConfig.Enabled || !GatewayConfig.SignalREnabled) + { + return; + } + + // Cleanup expired sessions every 60 seconds + Timer.StartTimer(TimeSpan.FromSeconds(60), TimeSpan.FromSeconds(60), Cleanup, out _cleanupTimer); + } + + /// + /// Adds a pushed session. Called on the game thread via Core.LoopContext.Post. + /// + public static void Add(PushedSession session) + { + _sessions[session.AuthId] = session; + logger.Debug("Cached pushed session AuthId=0x{AuthId:X8} for '{Username}'", session.AuthId, session.Username); + } + + /// + /// Tries to consume a session (one-time use). Returns true if found and not expired. + /// Called on the game thread from the login handler. + /// + public static bool TryConsume(int authId, out PushedSession session) + { + if (_sessions.Remove(authId, out session!)) + { + if (session.ExpiresAt > DateTime.UtcNow) + { + return true; + } + + // Expired + session = default!; + } + + return false; + } + + private static void Cleanup() + { + var now = DateTime.UtcNow; + var expired = new List(); + + foreach (var (authId, session) in _sessions) + { + if (session.ExpiresAt < now) + { + expired.Add(authId); + } + } + + foreach (var authId in expired) + { + _sessions.Remove(authId); + } + + if (expired.Count > 0) + { + logger.Debug("Cleaned up {Count} expired pushed sessions", expired.Count); + } + } +} + +public record PushedSession( + int AuthId, + Guid GameAccountId, + string Username, + string AccessLevel, + string ClientIP, + DateTime ExpiresAt +); diff --git a/Projects/UOContent/UOContent.csproj b/Projects/UOContent/UOContent.csproj index a127f62c4..730920330 100644 --- a/Projects/UOContent/UOContent.csproj +++ b/Projects/UOContent/UOContent.csproj @@ -36,6 +36,7 @@ + false