DI: Build root scope async while still registering events/commands during plugin init

This commit is contained in:
Liza 2023-02-18 04:34:49 +01:00
parent adddbc452c
commit f63e70b0c4
12 changed files with 401 additions and 268 deletions

View File

@ -1,141 +0,0 @@
using System;
using System.Linq;
using Dalamud.Game.ClientState;
using Dalamud.Game.Command;
using Dalamud.Game.Gui;
using ECommons.Schedulers;
using Pal.Client.Configuration;
using Pal.Client.DependencyInjection;
using Pal.Client.Extensions;
using Pal.Client.Properties;
using Pal.Client.Rendering;
using Pal.Client.Windows;
namespace Pal.Client.Commands
{
// should restructure this when more commands exist, if that ever happens
// this command is more-or-less a debug/troubleshooting command, if anything
internal sealed class PalCommand : IDisposable
{
private readonly IPalacePalConfiguration _configuration;
private readonly CommandManager _commandManager;
private readonly Chat _chat;
private readonly StatisticsService _statisticsService;
private readonly ConfigWindow _configWindow;
private readonly TerritoryState _territoryState;
private readonly FloorService _floorService;
private readonly ClientState _clientState;
public PalCommand(
IPalacePalConfiguration configuration,
CommandManager commandManager,
Chat chat,
StatisticsService statisticsService,
ConfigWindow configWindow,
TerritoryState territoryState,
FloorService floorService,
ClientState clientState)
{
_configuration = configuration;
_commandManager = commandManager;
_chat = chat;
_statisticsService = statisticsService;
_configWindow = configWindow;
_territoryState = territoryState;
_floorService = floorService;
_clientState = clientState;
_commandManager.AddHandler("/pal", new CommandInfo(OnCommand)
{
HelpMessage = Localization.Command_pal_HelpText
});
}
public void Dispose()
{
_commandManager.RemoveHandler("/pal");
}
private void OnCommand(string command, string arguments)
{
if (_configuration.FirstUse)
{
_chat.Error(Localization.Error_FirstTimeSetupRequired);
return;
}
try
{
arguments = arguments.Trim();
switch (arguments)
{
case "stats":
_statisticsService.ShowGlobalStatistics();
break;
case "test-connection":
case "tc":
_configWindow.IsOpen = true;
var _ = new TickScheduler(() => _configWindow.TestConnection());
break;
#if DEBUG
case "update-saves":
LocalState.UpdateAll();
_chat.Message(Localization.Command_pal_updatesaves);
break;
#endif
case "":
case "config":
_configWindow.Toggle();
break;
case "near":
DebugNearest(_ => true);
break;
case "tnear":
DebugNearest(m => m.Type == Marker.EType.Trap);
break;
case "hnear":
DebugNearest(m => m.Type == Marker.EType.Hoard);
break;
default:
_chat.Error(string.Format(Localization.Command_pal_UnknownSubcommand, arguments,
command));
break;
}
}
catch (Exception e)
{
_chat.Error(e.ToString());
}
}
private void DebugNearest(Predicate<Marker> predicate)
{
if (!_territoryState.IsInDeepDungeon())
return;
var state = _floorService.GetFloorMarkers(_clientState.TerritoryType);
var playerPosition = _clientState.LocalPlayer?.Position;
if (playerPosition == null)
return;
_chat.Message($"{playerPosition}");
var nearbyMarkers = state.Markers
.Where(m => predicate(m))
.Where(m => m.RenderElement != null && m.RenderElement.Color != RenderData.ColorInvisible)
.Select(m => new { m, distance = (playerPosition - m.Position)?.Length() ?? float.MaxValue })
.OrderBy(m => m.distance)
.Take(5)
.ToList();
foreach (var nearbyMarker in nearbyMarkers)
_chat.UnformattedMessage(
$"{nearbyMarker.distance:F2} - {nearbyMarker.m.Type} {nearbyMarker.m.NetworkId?.ToPartialId(length: 8)} - {nearbyMarker.m.Position}");
}
}
}

View File

@ -0,0 +1,31 @@
using Dalamud.Interface.Windowing;
using Pal.Client.Configuration;
using Pal.Client.Windows;
namespace Pal.Client.Commands
{
internal class PalConfigCommand
{
private readonly IPalacePalConfiguration _configuration;
private readonly AgreementWindow _agreementWindow;
private readonly ConfigWindow _configWindow;
public PalConfigCommand(
IPalacePalConfiguration configuration,
AgreementWindow agreementWindow,
ConfigWindow configWindow)
{
_configuration = configuration;
_agreementWindow = agreementWindow;
_configWindow = configWindow;
}
public void Execute()
{
if (_configuration.FirstUse)
_agreementWindow.IsOpen = true;
else
_configWindow.Toggle();
}
}
}

View File

@ -0,0 +1,67 @@
using System;
using System.Linq;
using Dalamud.Game.ClientState;
using Pal.Client.DependencyInjection;
using Pal.Client.Extensions;
using Pal.Client.Rendering;
namespace Pal.Client.Commands
{
internal sealed class PalNearCommand
{
private readonly Chat _chat;
private readonly ClientState _clientState;
private readonly TerritoryState _territoryState;
private readonly FloorService _floorService;
public PalNearCommand(Chat chat, ClientState clientState, TerritoryState territoryState,
FloorService floorService)
{
_chat = chat;
_clientState = clientState;
_territoryState = territoryState;
_floorService = floorService;
}
public void Execute(string arguments)
{
switch (arguments)
{
default:
DebugNearest(_ => true);
break;
case "tnear":
DebugNearest(m => m.Type == Marker.EType.Trap);
break;
case "hnear":
DebugNearest(m => m.Type == Marker.EType.Hoard);
break;
}
}
private void DebugNearest(Predicate<Marker> predicate)
{
if (!_territoryState.IsInDeepDungeon())
return;
var state = _floorService.GetFloorMarkers(_clientState.TerritoryType);
var playerPosition = _clientState.LocalPlayer?.Position;
if (playerPosition == null)
return;
_chat.Message($"{playerPosition}");
var nearbyMarkers = state.Markers
.Where(m => predicate(m))
.Where(m => m.RenderElement != null && m.RenderElement.Color != RenderData.ColorInvisible)
.Select(m => new { m, distance = (playerPosition - m.Position)?.Length() ?? float.MaxValue })
.OrderBy(m => m.distance)
.Take(5)
.ToList();
foreach (var nearbyMarker in nearbyMarkers)
_chat.UnformattedMessage(
$"{nearbyMarker.distance:F2} - {nearbyMarker.m.Type} {nearbyMarker.m.NetworkId?.ToPartialId(length: 8)} - {nearbyMarker.m.Position}");
}
}
}

View File

@ -0,0 +1,18 @@
using System;
using Pal.Client.DependencyInjection;
namespace Pal.Client.Commands
{
internal sealed class PalStatsCommand
{
private readonly StatisticsService _statisticsService;
public PalStatsCommand(StatisticsService statisticsService)
{
_statisticsService = statisticsService;
}
public void Execute()
=> _statisticsService.ShowGlobalStatistics();
}
}

View File

@ -0,0 +1,20 @@
using ECommons.Schedulers;
using Pal.Client.Windows;
namespace Pal.Client.Commands
{
internal sealed class PalTestConnectionCommand
{
private readonly ConfigWindow _configWindow;
public PalTestConnectionCommand(ConfigWindow configWindow)
{
_configWindow = configWindow;
}
public void Execute()
{
var _ = new TickScheduler(() => _configWindow.TestConnection());
}
}
}

View File

@ -52,7 +52,8 @@ namespace Pal.Client
public string Name => Localization.Palace_Pal;
public DependencyInjectionContext(DalamudPluginInterface pluginInterface,
public DependencyInjectionContext(
DalamudPluginInterface pluginInterface,
ClientState clientState,
GameGui gameGui,
ChatGui chatGui,
@ -70,7 +71,6 @@ namespace Pal.Client
#pragma warning restore CS0612
// set up logging
CancellationToken token = _initCts.Token;
IServiceCollection services = new ServiceCollection();
services.AddLogging(builder =>
builder.AddFilter("Pal", LogLevel.Trace)
@ -100,32 +100,37 @@ namespace Pal.Client
services.AddTransient<JsonMigration>();
// plugin-specific
services.AddSingleton<Plugin>();
services.AddSingleton<DebugState>();
services.AddSingleton<Hooks>();
services.AddSingleton<RemoteApi>();
services.AddSingleton<ConfigurationManager>();
services.AddSingleton<IPalacePalConfiguration>(sp => sp.GetRequiredService<ConfigurationManager>().Load());
services.AddScoped<DependencyInjectionLoader>();
services.AddScoped<DebugState>();
services.AddScoped<Hooks>();
services.AddScoped<RemoteApi>();
services.AddScoped<ConfigurationManager>();
services.AddScoped<IPalacePalConfiguration>(sp => sp.GetRequiredService<ConfigurationManager>().Load());
services.AddTransient<RepoVerification>();
services.AddSingleton<PalCommand>();
// commands
services.AddScoped<PalConfigCommand>();
services.AddScoped<PalNearCommand>();
services.AddScoped<PalStatsCommand>();
services.AddScoped<PalTestConnectionCommand>();
// territory & marker related services
services.AddSingleton<TerritoryState>();
services.AddSingleton<FrameworkService>();
services.AddSingleton<ChatService>();
services.AddSingleton<FloorService>();
services.AddSingleton<ImportService>();
services.AddScoped<TerritoryState>();
services.AddScoped<FrameworkService>();
services.AddScoped<ChatService>();
services.AddScoped<FloorService>();
services.AddScoped<ImportService>();
// windows & related services
services.AddSingleton<AgreementWindow>();
services.AddSingleton<ConfigWindow>();
services.AddTransient<StatisticsService>();
services.AddSingleton<StatisticsWindow>();
services.AddScoped<AgreementWindow>();
services.AddScoped<ConfigWindow>();
services.AddScoped<StatisticsService>();
services.AddScoped<StatisticsWindow>();
// these should maybe be scoped
services.AddScoped<SimpleRenderer>();
services.AddScoped<SplatoonRenderer>();
services.AddSingleton<RenderAdapter>();
services.AddScoped<RenderAdapter>();
// queue handling
services.AddTransient<IQueueOnFrameworkThread.Handler<QueuedImport>, QueuedImport.Handler>();
@ -161,63 +166,8 @@ namespace Pal.Client
// There's 2-3 seconds of slowdown primarily caused by the sqlite init, but that needs to happen for
// config stuff.
_logger = _serviceProvider.GetRequiredService<ILogger<DependencyInjectionContext>>();
_logger.LogInformation("Service container built, triggering async init");
Task.Run(async () =>
{
using IDisposable? logScope = _logger.BeginScope("AsyncInit");
Chat? chat = null;
try
{
_logger.LogInformation("Starting async init");
chat = _serviceProvider.GetService<Chat>();
// initialize database
await using (var scope = _serviceProvider.CreateAsyncScope())
{
_logger.LogInformation("Loading database & running migrations");
await using var dbContext = scope.ServiceProvider.GetRequiredService<PalClientContext>();
await dbContext.Database.MigrateAsync(token);
_logger.LogInformation("Completed database migrations");
}
token.ThrowIfCancellationRequested();
// v1 migration: config migration for import history, json migration for markers
_serviceProvider.GetRequiredService<ConfigurationManager>().Migrate();
await _serviceProvider.GetRequiredService<JsonMigration>().MigrateAsync(token);
token.ThrowIfCancellationRequested();
// windows that have logic to open on startup
_serviceProvider.GetRequiredService<AgreementWindow>();
// initialize components that are mostly self-contained/self-registered
_serviceProvider.GetRequiredService<Hooks>();
_serviceProvider.GetRequiredService<PalCommand>();
_serviceProvider.GetRequiredService<FrameworkService>();
_serviceProvider.GetRequiredService<ChatService>();
token.ThrowIfCancellationRequested();
_plugin = new Plugin(pluginInterface, _serviceProvider);
_logger.LogInformation("Async init complete");
}
catch (ObjectDisposedException)
{
}
catch (TaskCanceledException e)
{
_logger.LogError(e, "Task cancelled");
chat?.Error("Plugin was unloaded before it finished loading.");
}
catch (Exception e)
{
_logger.LogError(e, "Async load failed");
chat?.Error($"Async loading failed: {e.GetType()}: {e.Message}");
}
});
_logger.LogInformation("Service container built, creating plugin");
_plugin = new Plugin(pluginInterface, _serviceProvider, _initCts.Token);
}
public void Dispose()

View File

@ -0,0 +1,107 @@
using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Pal.Client.Commands;
using Pal.Client.Configuration;
using Pal.Client.Configuration.Legacy;
using Pal.Client.Database;
using Pal.Client.DependencyInjection;
using Pal.Client.Properties;
using Pal.Client.Windows;
namespace Pal.Client
{
/// <summary>
/// Takes care of async plugin init - this is mostly everything that requires either the config or the database to
/// be available.
/// </summary>
internal sealed class DependencyInjectionLoader
{
private readonly ILogger<DependencyInjectionLoader> _logger;
private readonly IServiceProvider _serviceProvider;
public DependencyInjectionLoader(ILogger<DependencyInjectionLoader> logger, IServiceProvider serviceProvider)
{
_logger = logger;
_serviceProvider = serviceProvider;
}
public ELoadState LoadState { get; private set; } = ELoadState.Initializing;
public event Action<Action?>? InitCompleted;
public async Task InitializeAsync(CancellationToken cancellationToken)
{
using IDisposable? logScope = _logger.BeginScope("AsyncInit");
Chat? chat = null;
try
{
_logger.LogInformation("Starting async init");
chat = _serviceProvider.GetService<Chat>();
// initialize database
await using (var scope = _serviceProvider.CreateAsyncScope())
{
_logger.LogInformation("Loading database & running migrations");
await using var dbContext = scope.ServiceProvider.GetRequiredService<PalClientContext>();
// takes 2-3 seconds with initializing connections, loading driver etc.
await dbContext.Database.MigrateAsync(cancellationToken);
_logger.LogInformation("Completed database migrations");
}
cancellationToken.ThrowIfCancellationRequested();
// v1 migration: config migration for import history, json migration for markers
_serviceProvider.GetRequiredService<ConfigurationManager>().Migrate();
await _serviceProvider.GetRequiredService<JsonMigration>().MigrateAsync(cancellationToken);
cancellationToken.ThrowIfCancellationRequested();
// windows that have logic to open on startup
_serviceProvider.GetRequiredService<AgreementWindow>();
// initialize components that are mostly self-contained/self-registered
_serviceProvider.GetRequiredService<Hooks>();
_serviceProvider.GetRequiredService<FrameworkService>();
_serviceProvider.GetRequiredService<ChatService>();
// eager load any commands to find errors now, not when running them
_serviceProvider.GetRequiredService<PalConfigCommand>();
_serviceProvider.GetRequiredService<PalNearCommand>();
_serviceProvider.GetRequiredService<PalStatsCommand>();
_serviceProvider.GetRequiredService<PalTestConnectionCommand>();
cancellationToken.ThrowIfCancellationRequested();
LoadState = ELoadState.Loaded;
InitCompleted?.Invoke(null);
_logger.LogInformation("Async init complete");
}
catch (ObjectDisposedException)
{
InitCompleted?.Invoke(null);
LoadState = ELoadState.Error;
}
catch (Exception e)
{
_logger.LogError(e, "Async load failed");
InitCompleted?.Invoke(() => chat?.Error(string.Format(Localization.Error_LoadFailed, $"{e.GetType()} - {e.Message}")));
LoadState = ELoadState.Error;
}
}
public enum ELoadState
{
Initializing,
Loaded,
Error
}
}
}

View File

@ -5,11 +5,17 @@ using Pal.Client.Windows;
using System;
using System.Globalization;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Dalamud.Game.ClientState;
using Dalamud.Game.Command;
using Pal.Client.Properties;
using ECommons;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Pal.Client.Commands;
using Pal.Client.Configuration;
using Pal.Client.DependencyInjection;
namespace Pal.Client
{
@ -17,51 +23,120 @@ namespace Pal.Client
/// With all DI logic elsewhere, this plugin shell really only takes care of a few things around events that
/// need to be sent to different receivers depending on priority or configuration .
/// </summary>
/// <see cref="DependencyInjection.DependencyInjectionContext"/>
/// <see cref="DependencyInjectionContext"/>
internal sealed class Plugin : IDisposable
{
private readonly DalamudPluginInterface _pluginInterface;
private readonly IServiceProvider _serviceProvider;
private readonly ILogger<Plugin> _logger;
private readonly IPalacePalConfiguration _configuration;
private readonly RenderAdapter _renderAdapter;
private readonly CommandManager _commandManager;
private readonly Chat _chat;
private readonly WindowSystem _windowSystem;
private readonly ClientState _clientState;
private readonly IServiceScope _rootScope;
private readonly DependencyInjectionLoader _loader;
private Action? _loginAction = null;
public Plugin(
DalamudPluginInterface pluginInterface,
IServiceProvider serviceProvider)
IServiceProvider serviceProvider,
CancellationToken cancellationToken)
{
_pluginInterface = pluginInterface;
_serviceProvider = serviceProvider;
_logger = _serviceProvider.GetRequiredService<ILogger<Plugin>>();
_configuration = serviceProvider.GetRequiredService<IPalacePalConfiguration>();
_renderAdapter = serviceProvider.GetRequiredService<RenderAdapter>();
_logger = serviceProvider.GetRequiredService<ILogger<Plugin>>();
_commandManager = serviceProvider.GetRequiredService<CommandManager>();
_chat = serviceProvider.GetRequiredService<Chat>();
_windowSystem = serviceProvider.GetRequiredService<WindowSystem>();
_clientState = serviceProvider.GetRequiredService<ClientState>();
LanguageChanged(pluginInterface.UiLanguage);
_rootScope = serviceProvider.CreateScope();
_loader = _rootScope.ServiceProvider.GetRequiredService<DependencyInjectionLoader>();
_loader.InitCompleted += InitCompleted;
var _ = Task.Run(async () => await _loader.InitializeAsync(cancellationToken));
pluginInterface.UiBuilder.Draw += Draw;
pluginInterface.UiBuilder.OpenConfigUi += OpenConfigUi;
pluginInterface.LanguageChanged += LanguageChanged;
_clientState.Login += Login;
_commandManager.AddHandler("/pal", new CommandInfo(OnCommand)
{
HelpMessage = Localization.Command_pal_HelpText
});
}
private void InitCompleted(Action? loginAction)
{
LanguageChanged(_pluginInterface.UiLanguage);
if (_clientState.IsLoggedIn)
{
loginAction?.Invoke();
_loginAction = null;
}
else
_loginAction = loginAction;
}
private void Login(object? sender, EventArgs eventArgs)
{
_loginAction?.Invoke();
_loginAction = null;
}
private void OnCommand(string command, string arguments)
{
arguments = arguments.Trim();
IPalacePalConfiguration configuration =
_rootScope.ServiceProvider.GetRequiredService<IPalacePalConfiguration>();
if (configuration.FirstUse && arguments != "" && arguments != "config")
{
_chat.Error(Localization.Error_FirstTimeSetupRequired);
return;
}
try
{
var sp = _rootScope.ServiceProvider;
switch (arguments)
{
case "":
case "config":
sp.GetRequiredService<PalConfigCommand>().Execute();
break;
case "stats":
sp.GetRequiredService<PalStatsCommand>().Execute();
break;
case "tc":
case "test-connection":
sp.GetRequiredService<PalTestConnectionCommand>().Execute();
break;
case "near":
case "tnear":
case "hnear":
sp.GetRequiredService<PalNearCommand>().Execute(arguments);
break;
default:
_chat.Error(string.Format(Localization.Command_pal_UnknownSubcommand, arguments,
command));
break;
}
}
catch (Exception e)
{
_chat.Error(e.ToString());
}
}
private void OpenConfigUi()
{
Window configWindow;
if (_configuration.FirstUse)
configWindow = _serviceProvider.GetRequiredService<AgreementWindow>();
else
configWindow = _serviceProvider.GetRequiredService<ConfigWindow>();
configWindow.IsOpen = true;
}
public void Dispose()
{
_pluginInterface.UiBuilder.Draw -= Draw;
_pluginInterface.UiBuilder.OpenConfigUi -= OpenConfigUi;
_pluginInterface.LanguageChanged -= LanguageChanged;
}
=> _rootScope.ServiceProvider.GetRequiredService<PalConfigCommand>().Execute();
private void LanguageChanged(string languageCode)
{
@ -74,8 +149,24 @@ namespace Pal.Client
private void Draw()
{
_renderAdapter.DrawLayers();
_windowSystem.Draw();
if (_loader.LoadState == DependencyInjectionLoader.ELoadState.Loaded)
{
_rootScope.ServiceProvider.GetRequiredService<RenderAdapter>().DrawLayers();
_windowSystem.Draw();
}
}
public void Dispose()
{
_commandManager.RemoveHandler("/pal");
_pluginInterface.UiBuilder.Draw -= Draw;
_pluginInterface.UiBuilder.OpenConfigUi -= OpenConfigUi;
_pluginInterface.LanguageChanged -= LanguageChanged;
_clientState.Login -= Login;
_loader.InitCompleted -= InitCompleted;
_rootScope.Dispose();
}
}
}

View File

@ -149,15 +149,6 @@ namespace Pal.Client.Properties {
}
}
/// <summary>
/// Looks up a localized string similar to Updated all locally cached marker files to latest version..
/// </summary>
internal static string Command_pal_updatesaves {
get {
return ResourceManager.GetString("Command_pal_updatesaves", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to You are NOT in a deep dungeon..
/// </summary>
@ -664,6 +655,15 @@ namespace Pal.Client.Properties {
}
}
/// <summary>
/// Looks up a localized string similar to Plugin could not be loaded: {0}.
/// </summary>
internal static string Error_LoadFailed {
get {
return ResourceManager.GetString("Error_LoadFailed", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Please install this plugin from the official repository at {0} to continue using it..
/// </summary>

View File

@ -61,10 +61,6 @@
<value>Impossible de récupérer les statistiques.</value>
<comment>Shown when /pal stats produces a server-side error, and the statistics window can't be loaded.</comment>
</data>
<data name="Command_pal_updatesaves" xml:space="preserve">
<value>Mise à jour de tous les marqueurs du cache local vers la dernière version.</value>
<comment>Shown after /pal update-saves was successful.</comment>
</data>
<!-- Messages while connecting to the server. These are typically only visible when clicking 'Test connection'. -->
<data name="ConnectionSuccessful" xml:space="preserve">
<value>Connexion réussie.</value>

View File

@ -61,10 +61,6 @@
<value>統計情報を取得できません。</value>
<comment>Shown when /pal stats produces a server-side error, and the statistics window can't be loaded.</comment>
</data>
<data name="Command_pal_updatesaves" xml:space="preserve">
<value>保存されたマーカーファイルを更新しました。</value>
<comment>Shown after /pal update-saves was successful.</comment>
</data>
<!-- Messages while connecting to the server. These are typically only visible when clicking 'Test connection'. -->
<data name="ConnectionSuccessful" xml:space="preserve">
<value>接続に成功しました。</value>

View File

@ -46,6 +46,9 @@
<value>Please finish the initial setup first.</value>
<comment>Before using any /pal command, the initial setup/agreeement needs to be completed.</comment>
</data>
<data name="Error_LoadFailed" xml:space="preserve">
<value>Plugin could not be loaded: {0}</value>
</data>
<data name="Error_WrongRepository" xml:space="preserve">
<value>Please install this plugin from the official repository at {0} to continue using it.</value>
</data>
@ -66,11 +69,7 @@
<value>Unable to fetch statistics.</value>
<comment>Shown when /pal stats produces a server-side error, and the statistics window can't be loaded.</comment>
</data>
<data name="Command_pal_updatesaves" xml:space="preserve">
<value>Updated all locally cached marker files to latest version.</value>
<comment>Shown after /pal update-saves was successful.</comment>
</data>
<!-- Messages while connecting to the server. These are typically only visible when clicking 'Test connection'. -->
<data name="ConnectionSuccessful" xml:space="preserve">
<value>Connection successful.</value>
@ -322,6 +321,5 @@ This is not synchronized with other players and not saved between floors/runs.</
<data name="Error_ImportFailed_InvalidFile" xml:space="preserve">
<value>Import failed: Invalid file.</value>
</data>
<!-- Other -->
</root>