// // Licensed under the MIT License. See LICENSE file in the project root for full license information. // namespace MUnique.OpenMU.Startup; using System; using System.ComponentModel.Design; using System.Diagnostics; using System.IO; using System.Text.Json.Serialization; using System.Threading; using Microsoft.AspNetCore.Builder; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using MUnique.OpenMU.ChatServer; using MUnique.OpenMU.ConnectServer; using MUnique.OpenMU.DataModel.Configuration; using MUnique.OpenMU.FriendServer; using MUnique.OpenMU.GameLogic; using MUnique.OpenMU.GuildServer; using MUnique.OpenMU.Interfaces; using MUnique.OpenMU.LoginServer; using MUnique.OpenMU.Network; using MUnique.OpenMU.Persistence; using MUnique.OpenMU.Persistence.EntityFramework; using MUnique.OpenMU.Persistence.EntityFramework.Json; using MUnique.OpenMU.Persistence.Initialization; using MUnique.OpenMU.Persistence.Initialization.Version075; using MUnique.OpenMU.Persistence.InMemory; using MUnique.OpenMU.PlugIns; using MUnique.OpenMU.Web.AdminPanel; using MUnique.OpenMU.Web.AdminPanel.Services; using MUnique.OpenMU.Web.API; using MUnique.OpenMU.Web.Map.Map; using MUnique.OpenMU.Web.Shared; using Nito.AsyncEx.Synchronous; using Serilog; using Serilog.Debugging; /// /// The startup class for an all-in-one game server. /// internal sealed class Program : IDisposable { private static bool _confirmExit; private static SystemConfiguration? _systemConfiguration; private readonly IDictionary _gameServers = new Dictionary(); private readonly IList _servers = new List(); private readonly Serilog.ILogger _logger; private readonly IConfiguration _configuration; private IHost? _serverHost; /// /// Initializes a new instance of the class. /// public Program() { AppDomain.CurrentDomain.UnhandledException += this.OnUnhandledException; SelfLog.Enable(Console.Error); var configuration = new ConfigurationBuilder() .SetBasePath(Directory.GetCurrentDirectory()) .AddJsonFile("appsettings.json", false, true) .AddJsonFile($"appsettings.{Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") ?? "Production"}.json", true, true) .AddEnvironmentVariables() .Build(); this._configuration = configuration; this._logger = new LoggerConfiguration() .ReadFrom.Configuration(configuration) .CreateLogger(); } /// /// The main method. /// /// The command line args. public static async Task Main(string[] args) { using var exitCts = new CancellationTokenSource(); var exitToken = exitCts.Token; void OnCancelKeyPress(object? sender, ConsoleCancelEventArgs e) { if (_confirmExit) { #pragma warning disable VSTHRD103 // Call async methods when in an async method exitCts.Cancel(); #pragma warning restore VSTHRD103 // Call async methods when in an async method Console.CancelKeyPress -= OnCancelKeyPress; Console.WriteLine("\nBye! Press enter to finish"); } else { _confirmExit = true; Console.Write("\nConfirm shutdown? (y/N) "); } } Console.CancelKeyPress += OnCancelKeyPress; AppDomain.CurrentDomain.ProcessExit += (sender, eventArgs) => { if (!exitToken.IsCancellationRequested) { #pragma warning disable VSTHRD103 // Call async methods when in an async method exitCts.Cancel(); #pragma warning restore VSTHRD103 // Call async methods when in an async method Debug.WriteLine("KILL"); } }; using var program = new Program(); await program.InitializeAsync(args).ConfigureAwait(false); while (!exitToken.IsCancellationRequested) { await Task.Delay(100).ConfigureAwait(false); if (_systemConfiguration?.ReadConsoleInput is true) { await HandleConsoleInputAsync(exitCts, exitToken).ConfigureAwait(false); } } } /// /// Initializes a new instance of the class. /// /// The command line args. public async Task InitializeAsync(string[] args) { JsonConverterRegistry.RegisterConverter(new LocalizedStringJsonConverter()); JsonConverterRegistry.RegisterConverter(new BinaryAsHexJsonConverter()); this._logger.Information("Creating host..."); this._serverHost = await this.CreateHostAsync(args).ConfigureAwait(false); var autoStart = _systemConfiguration?.AutoStart is true || args.Contains("-autostart") || !this.IsAdminPanelEnabled(args); if (_systemConfiguration is { } && this._serverHost.Services.GetService() is ConfigurableIpResolver resolver) { resolver.Configure(_systemConfiguration.IpResolver, _systemConfiguration.IpResolverParameter); } if (autoStart) { foreach (var chatServer in this._servers.OfType()) { await chatServer.StartAsync().ConfigureAwait(false); } foreach (var gameServer in this._gameServers.Values) { await gameServer.StartAsync().ConfigureAwait(false); } foreach (var connectServer in this._servers.OfType()) { await connectServer.StartAsync().ConfigureAwait(false); } } } /// public void Dispose() { this._serverHost?.StopAsync().WaitAndUnwrapException(); this._serverHost?.Dispose(); } private static void DisplayCommands() { var commandList = "help, exit, gc, pid"; Console.WriteLine($"Commands available: {commandList}"); } private static async Task HandleConsoleInputAsync(CancellationTokenSource exitCts, CancellationToken exitToken) { Console.Write("> "); var input = (await Console.In.ReadLineAsync(exitToken).ConfigureAwait(false))?.ToLower(); switch (input) { case "y" when _confirmExit: case "exit": await exitCts.CancelAsync().ConfigureAwait(false); break; case "gc": GC.Collect(); Console.WriteLine("Garbage Collected!"); break; case "pid": var process = Process.GetCurrentProcess(); var pid = process.Id.ToString(); Console.WriteLine($"PID: {pid}"); break; case "?": case "help": DisplayCommands(); break; case "": case null: break; default: Console.WriteLine("Unknown command"); DisplayCommands(); break; } if (_confirmExit && !string.IsNullOrWhiteSpace(input)) { _confirmExit = false; } } private void OnUnhandledException(object sender, UnhandledExceptionEventArgs e) { if (e.IsTerminating) { this._logger.Fatal(e.ExceptionObject as Exception, "Unhandled exception leading to terminating application: {0}", e.ExceptionObject); } else { this._logger.Error(e.ExceptionObject as Exception, "Unhandled exception: {0}", e.ExceptionObject); } } private async Task CreateHostAsync(string[] args) { // Ensure GameLogic and GameServer Assemblies are loaded _ = GameLogic.Rand.NextInt(1, 2); _ = DataInitialization.Id; _ = OpenMU.GameServer.ClientVersionResolver.DefaultVersion; var addAdminPanel = this.IsAdminPanelEnabled(args); await new ConfigFileDatabaseConnectionStringProvider().InitializeAsync(default).ConfigureAwait(false); var builder = WebApplication.CreateBuilder(args); builder.Host.UseSerilog(this._logger); if (addAdminPanel) { builder.AddAdminPanel(includeMapApp: true); } builder.Services.AddSingleton(this._servers) .AddSingleton() .AddSingleton() .AddSingleton() .AddSingleton(s => s.GetRequiredService()) .AddSingleton(s => s.GetRequiredService()) .AddSingleton(s => this.CreateIpResolver(s, args)) .AddSingleton(this._gameServers) .AddSingleton(this._gameServers.Values) .AddSingleton(s => this.DeterminePersistenceContextProviderAsync( args, s.GetService() ?? throw new InvalidOperationException($"{nameof(ILoggerFactory)} not registered."), s.GetService() ?? throw new InvalidOperationException($"{nameof(IConfigurationChangeListener)} not registered."), s.GetService() ?? throw new InvalidOperationException($"{nameof(IConfigurationChangePublisher)} not registered.")) .WaitAndUnwrapException()) .AddSingleton(s => s.GetService()!) .AddSingleton>(s => new(() => s.GetService()!)) .AddSingleton() .AddSingleton() .AddSingleton() .AddSingleton() .AddSingleton(s => s.GetService()!) .AddSingleton() .AddSingleton() .AddSingleton(provider => provider.GetService()!) .AddSingleton() .AddSingleton(provider => provider.GetService()!) .AddScoped() .AddSingleton() .AddSingleton>(provider => provider.GetService() ?? throw new Exception($"{nameof(ConnectServerContainer)} not registered.")) .AddSingleton() .AddSingleton() .AddSingleton() .AddSingleton() .AddSingleton>(this.PlugInConfigurationsFactory) .AddTransient(provider => { var persistenceContextProvider = provider.GetService(); var dataSource = new GameConfigurationDataSource( provider.GetService>()!, persistenceContextProvider!); var configId = persistenceContextProvider!.CreateNewConfigurationContext().GetDefaultGameConfigurationIdAsync(default).AsTask().WaitAndUnwrapException(); dataSource.GetOwnerAsync(configId!.Value).AsTask().WaitAndUnwrapException(); var referenceHandler = new ByDataSourceReferenceHandler(dataSource); return referenceHandler; }) .AddSingleton, GameConfigurationDataSource>() .AddHostedService() .AddHostedService() .AddHostedService(provider => provider.GetService()!) .AddHostedService(provider => provider.GetService()!) .AddControllers().AddApplicationPart(typeof(ServerController).Assembly); var host = builder.Build(); // NpgsqlLoggingConfiguration.InitializeLogging(host.Services.GetRequiredService()) this._logger.Information("Host created"); if (addAdminPanel) { host.ConfigureAdminPanel(); } this._logger.Information("Starting host..."); var stopwatch = new Stopwatch(); stopwatch.Start(); await host.StartAsync().ConfigureAwait(false); stopwatch.Stop(); this._logger.Information("Host started, elapsed time: {elapsed}", stopwatch.Elapsed); this._logger.Information("Admin Panel bound to urls: {urls}", string.Join("; ", host.Urls)); return host; } private IIpAddressResolver CreateIpResolver(IServiceProvider serviceProvider, string[] args) { (IpResolverType IpResolver, string? IpResolverParameter)? settings = default; if (_systemConfiguration is not null) { settings = (_systemConfiguration.IpResolver, _systemConfiguration.IpResolverParameter); } return IpAddressResolverFactory.CreateIpResolver(args, settings, serviceProvider.GetService()!); } private ICollection PlugInConfigurationsFactory(IServiceProvider serviceProvider) { var persistenceContextProvider = serviceProvider.GetService() ?? throw new Exception($"{nameof(IPersistenceContextProvider)} not registered."); using var context = persistenceContextProvider.CreateNewTypedContext(typeof(PlugInConfiguration), false); var configs = context.GetAsync().AsTask().WaitAndUnwrapException().ToList(); var referenceHandler = new ByDataSourceReferenceHandler( new GameConfigurationDataSource(serviceProvider.GetService>()!, persistenceContextProvider)); // We check if we miss any plugin configurations in the database. If we do, we try to add them. var pluginManager = new PlugInManager(null, serviceProvider.GetService()!, serviceProvider, referenceHandler); pluginManager.DiscoverAndRegisterPlugIns(); var typesWithCustomConfig = pluginManager.KnownPlugInTypes.Where(t => t.GetInterfaces().Contains(typeof(ISupportDefaultCustomConfiguration))).ToDictionary(t => t.GUID, t => t); using var notificationSuspension = context.SuspendChangeNotifications(); var typesWithMissingCustomConfigs = configs.Where(c => string.IsNullOrWhiteSpace(c.CustomConfiguration) && typesWithCustomConfig.ContainsKey(c.TypeId)).ToList(); if (typesWithMissingCustomConfigs.Any()) { typesWithMissingCustomConfigs.ForEach(c => this.CreateDefaultPlugInConfiguration(typesWithCustomConfig[c.TypeId]!, c, referenceHandler)); _ = context.SaveChangesAsync().AsTask().WaitAndUnwrapException(); } var typesWithMissingConfigs = pluginManager.KnownPlugInTypes.Where(t => configs.All(c => c.TypeId != t.GUID)).ToList(); if (!typesWithMissingConfigs.Any()) { return configs; } configs.AddRange(this.CreateMissingPlugInConfigurations(typesWithMissingConfigs, persistenceContextProvider, referenceHandler)); _ = context.SaveChangesAsync().AsTask().WaitAndUnwrapException(); return configs; } private IEnumerable CreateMissingPlugInConfigurations(IEnumerable plugInTypes, IPersistenceContextProvider persistenceContextProvider, ReferenceHandler referenceHandler) { GameConfiguration gameConfiguration; using (var context = persistenceContextProvider.CreateNewContext()) { gameConfiguration = context.GetAsync().AsTask().WaitAndUnwrapException().First(); } using var saveContext = persistenceContextProvider.CreateNewContext(gameConfiguration); saveContext.Attach(gameConfiguration); foreach (var plugInType in plugInTypes) { var plugInConfiguration = saveContext.CreateNew(); plugInConfiguration.TypeId = plugInType.GUID; plugInConfiguration.IsActive = !plugInType.IsAssignableTo(typeof(IDisabledByDefault)); gameConfiguration.PlugInConfigurations.Add(plugInConfiguration); if (plugInType.GetInterfaces().Contains(typeof(ISupportDefaultCustomConfiguration))) { this.CreateDefaultPlugInConfiguration(plugInType, plugInConfiguration, referenceHandler); } yield return plugInConfiguration; } using var notificationSuspension = saveContext.SuspendChangeNotifications(); _ = saveContext.SaveChangesAsync().AsTask().WaitAndUnwrapException(); } private void CreateDefaultPlugInConfiguration(Type plugInType, PlugInConfiguration plugInConfiguration, ReferenceHandler referenceHandler) { try { var plugin = (ISupportDefaultCustomConfiguration)Activator.CreateInstance(plugInType)!; var defaultConfig = plugin.CreateDefaultConfig(); plugInConfiguration.SetConfiguration(defaultConfig, referenceHandler); } catch (Exception ex) { this._logger.Warning(ex, "Could not create custom default configuration for plugin type {plugInType}", plugInType); } } private ushort DetermineUshort(string parameterName, string[] args, ushort defaultValue) { var parameter = args.FirstOrDefault(a => a.StartsWith($"-{parameterName}:", StringComparison.InvariantCultureIgnoreCase)); if (parameter != null && int.TryParse(parameter.Substring(parameter.IndexOf(':') + 1), out int value) && value is >= 0 and <= ushort.MaxValue) { return (ushort)value; } return defaultValue; } private bool IsAdminPanelEnabled(string[] args) => this.IsFeatureEnabled("adminpanel", args); private bool IsFeatureEnabled(string featureName, string[] args) { var parameter = args.FirstOrDefault(a => a.StartsWith($"-{featureName}:", StringComparison.InvariantCultureIgnoreCase)); if (parameter is null) { return true; } return parameter.Substring(parameter.IndexOf(':') + 1).StartsWith("enabled", StringComparison.InvariantCultureIgnoreCase); } private string GetVersionParameter(string[] args) { var parameter = args.FirstOrDefault(a => a.StartsWith("-version:", StringComparison.InvariantCultureIgnoreCase)); if (parameter is null) { return "season6"; // default } return parameter.Substring(parameter.IndexOf(':') + 1); } private async Task DeterminePersistenceContextProviderAsync(string[] args, ILoggerFactory loggerFactory, IConfigurationChangeListener changeListener, IConfigurationChangePublisher changePublisher) { var version = this.GetVersionParameter(args); IMigratableDatabaseContextProvider contextProvider; if (args.Contains("-demo")) { var inMemoryProvider = new InMemoryPersistenceContextProvider(); contextProvider = inMemoryProvider; await this.InitializeDataAsync(version, loggerFactory, contextProvider).ConfigureAwait(false); inMemoryProvider.ChangePublisher = changePublisher; } else { contextProvider = await this.PrepareRepositoryProviderAsync(args.Contains("-reinit"), version, loggerFactory, changeListener).ConfigureAwait(false); } await this.ReadSystemConfigurationAsync(contextProvider).ConfigureAwait(false); return contextProvider; } private async Task PrepareRepositoryProviderAsync(bool reinit, string version, ILoggerFactory loggerFactory, IConfigurationChangeListener changeListener) { var contextProvider = new PersistenceContextProvider(loggerFactory, changeListener); if (reinit || !await contextProvider.DatabaseExistsAsync().ConfigureAwait(false)) { // The database can be provisioned externally (e.g. by a Kubernetes operator, // infrastructure-as-code, or a managed cloud database), where the connecting role is // typically not permitted to create or drop databases. Enabling // Database:AssumeExternallyProvisioned keeps the existing database and only builds its // schema via migrations, instead of dropping and recreating it. The default (false) // preserves the original drop-and-recreate behaviour; an explicit -reinit always recreates. var assumeExternallyProvisioned = !reinit && this._configuration.GetValue("Database:AssumeExternallyProvisioned"); this._logger.Information(assumeExternallyProvisioned ? "Building the schema on the externally-provisioned database (no drop/create)..." : "The database is getting (re-)initialized..."); using var update = await contextProvider.ReCreateDatabaseAsync(dropExistingDatabase: !assumeExternallyProvisioned).ConfigureAwait(false); await this.InitializeDataAsync(version, loggerFactory, contextProvider).ConfigureAwait(false); this._logger.Information("...initialization finished."); } else if (!await contextProvider.IsDatabaseUpToDateAsync().ConfigureAwait(false)) { if (_systemConfiguration?.AutoUpdateSchema is true || await contextProvider.ShouldDoAutoSchemaUpdateAsync()) { Console.WriteLine("The database schema needs to be updated before the server can be started. Updating..."); await contextProvider.ApplyAllPendingUpdatesAsync().ConfigureAwait(false); Console.WriteLine("The database schema has been successfully updated."); } else { Console.WriteLine("The database schema needs to be updated before the server can be started. Apply update? (y/n)"); var key = Console.ReadLine()?.ToLowerInvariant(); if (key == "y") { await contextProvider.ApplyAllPendingUpdatesAsync().ConfigureAwait(false); Console.WriteLine("The database schema has been successfully updated."); } else { Console.WriteLine("Cancelled the schema update process, can't start the server."); return null!; } } } else { // everything is fine and ready } return contextProvider; } private async Task ReadSystemConfigurationAsync(IPersistenceContextProvider persistenceContextProvider) { using var context = persistenceContextProvider.CreateNewTypedContext(typeof(SystemConfiguration), false); var config = (await context.GetAsync().ConfigureAwait(false)).FirstOrDefault(); if (config != null) { _systemConfiguration = config; } } private async Task InitializeDataAsync(string version, ILoggerFactory loggerFactory, IPersistenceContextProvider contextProvider) { var serviceContainer = new ServiceContainer(); serviceContainer.AddService(typeof(ILoggerFactory), loggerFactory); serviceContainer.AddService(typeof(IPersistenceContextProvider), contextProvider); var referenceHandler = new ByDataSourceReferenceHandler( new GameConfigurationDataSource(serviceContainer.GetService>()!, contextProvider)); var plugInManager = new PlugInManager(null, loggerFactory, serviceContainer, referenceHandler); plugInManager.DiscoverAndRegisterPlugInsOf(); var initialization = plugInManager.GetStrategy(version) ?? throw new Exception("Data initialization plugin not found"); await initialization.CreateInitialDataAsync(3, true).ConfigureAwait(false); } }