using System.Collections.Concurrent; using System.Linq.Expressions; using LANCommander.SDK.Enums; using LANCommander.Server.Services.Abstractions; using LANCommander.Server.Services.Enums; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; namespace LANCommander.Server.Services; /// /// Singleton coordinator for the full lifetime of game servers. Owns engine routing (which /// manages a given server), tracking refresh, autostart (including /// the boot-time pass), and debounced autostop. Server lifecycle is process-wide state, so it /// can't live in the request-scoped . /// public sealed class ServerManager( ILogger logger, IServiceScopeFactory scopeFactory, IEnumerable serverEngines, SettingsProvider settingsProvider) { // Pending debounced stops keyed by gameId. Held here (singleton) because the request-scoped // ServerService can't keep pending-stop state across requests. private readonly ConcurrentDictionary _pendingStops = new(); #region Initialization & tracking /// /// Initializes every engine and primes its tracking. Called once at application start. /// public async Task InitializeAsync() { foreach (var engine in serverEngines) { await engine.InitializeAsync(); await engine.RefreshTrackingAsync(); } } /// /// Re-syncs each engine's tracked-server set with the database. Called after a server is /// added or edited so freshly added/edited servers become immediately manageable. /// public async Task RefreshTrackingAsync() { foreach (var engine in serverEngines) await engine.RefreshTrackingAsync(); } public bool IsManaging(Guid serverId) => serverEngines.Any(engine => engine.IsManaging(serverId)); #endregion #region Routing public async Task StartAsync(Guid serverId) { foreach (var engine in serverEngines) { if (engine.IsManaging(serverId)) { await engine.StartAsync(serverId); return; } } } public async Task StopAsync(Guid serverId) { foreach (var engine in serverEngines) { if (engine.IsManaging(serverId)) { await engine.StopAsync(serverId); return; } } } public async Task GetStatusAsync(Guid serverId) { foreach (var engine in serverEngines) { if (engine.IsManaging(serverId)) return await engine.GetStatusAsync(serverId); } return ServerProcessStatus.Stopped; } #endregion #region Autostart /// /// Starts a game's autostart servers matching the given method. Each start is fire-and-forget /// because blocks for the server process's lifetime. /// Cancels any pending debounced stop for the game first, so a returning player keeps the /// servers running. /// public async Task AutostartAsync(Guid gameId, ServerAutostartMethod method) { CancelPendingStop(gameId); try { var servers = await GetServersAsync(s => s.GameId == gameId && s.Autostart && s.AutostartMethod == method); foreach (var engine in serverEngines) { foreach (var server in servers) { try { var status = await engine.GetStatusAsync(server.Id); if (engine.IsManaging(server.Id) && status == ServerProcessStatus.Stopped) _ = engine.StartAsync(server.Id); } catch (Exception ex) { logger.LogError(ex, "Failed to start server {ServerName} ({ServerId})", server.Name, server.Id); } } } } catch (Exception ex) { logger.LogError(ex, "Servers could not be autostarted"); } } /// /// Autostarts every server configured for , /// honoring each server's configured startup delay. Called once at boot. /// public async Task AutostartApplicationServersAsync() { var servers = await GetServersAsync(s => s.Autostart && s.AutostartMethod == ServerAutostartMethod.OnApplicationStart); foreach (var server in servers) { var serverId = server.Id; var serverName = server.Name; var delaySeconds = server.AutostartDelay; logger.LogDebug("Autostarting server {ServerName} with a delay of {AutostartDelay} seconds", serverName, delaySeconds); _ = Task.Run(async () => { try { if (delaySeconds > 0) await Task.Delay(TimeSpan.FromSeconds(delaySeconds)); await StartAsync(serverId); } catch (Exception ex) { logger.LogError(ex, "An unexpected error occurred while trying to autostart the server {ServerName}", serverName); } }); } } #endregion #region Autostop /// /// Immediately stops a game's autostart servers matching the given method. /// public async Task AutostopAsync(Guid gameId, ServerAutostartMethod method) { try { var servers = await GetServersAsync(s => s.GameId == gameId && s.Autostart && s.AutostartMethod == method); foreach (var engine in serverEngines) { foreach (var server in servers) { try { var status = await engine.GetStatusAsync(server.Id); if (engine.IsManaging(server.Id) && status != ServerProcessStatus.Stopped) await engine.StopAsync(server.Id); } catch (Exception ex) { logger.LogError(ex, "Failed to stop server {ServerName} ({ServerId})", server.Name, server.Id); } } } } catch (Exception ex) { logger.LogError(ex, "Servers could not be autostopped"); } } /// /// Stops every tracked server across all engines. Used when the application needs to bring /// everything down (e.g. before applying a server update). /// public async Task StopAllAsync() { var servers = await GetServersAsync(_ => true); foreach (var engine in serverEngines) { foreach (var server in servers) { if (engine.IsManaging(server.Id)) await engine.StopAsync(server.Id); } } } /// /// Cancels any pending autostop for the game (e.g. a player relaunched within the delay). /// public void CancelPendingStop(Guid gameId) { if (_pendingStops.TryRemove(gameId, out var cts)) { cts.Cancel(); cts.Dispose(); } } /// /// Schedules the game's on-player-activity servers to stop after the configured delay, /// resetting any already-pending stop. Debounces stop/start thrash when players relaunch. /// public void ScheduleStop(Guid gameId) { var delay = TimeSpan.FromSeconds(Math.Max(0, settingsProvider.CurrentValue.Server.GameServers.AutostopDelay)); CancelPendingStop(gameId); var cts = new CancellationTokenSource(); _pendingStops[gameId] = cts; _ = RunDelayedStopAsync(gameId, delay, cts.Token); } private async Task RunDelayedStopAsync(Guid gameId, TimeSpan delay, CancellationToken cancellationToken) { try { await Task.Delay(delay, cancellationToken); using var scope = scopeFactory.CreateScope(); var playSessionService = scope.ServiceProvider.GetRequiredService(); // Re-check after the delay: a player may have started again without us having observed // a cancellation, so the authoritative source is the session table. var activeSessions = await playSessionService.GetAsync(ps => ps.GameId == gameId && ps.End == null); if (!activeSessions.Any()) await AutostopAsync(gameId, ServerAutostartMethod.OnPlayerActivity); } catch (OperationCanceledException) { // A player relaunched within the debounce window; leave the servers running. } catch (Exception ex) { logger.LogError(ex, "Failed to autostop servers for game {GameId}", gameId); } finally { _pendingStops.TryRemove(gameId, out _); } } #endregion private async Task> GetServersAsync(Expression> predicate) { using var scope = scopeFactory.CreateScope(); var serverService = scope.ServiceProvider.GetRequiredService(); return await serverService.GetAsync(predicate); } }