feat(bootstrap): wire DI host, hosted services, and plugin entry
The plugin is now loadable. Dalamud injects five services into the Plugin constructor (Lightless pattern), the constructor builds the generic-host container synchronously, and PluginLifecycle drives StartAsync from LoadAsync. Module 02+ extends PluginHostFactory; this file set stays put. - PluginHostDependencies (record): bundles the five Dalamud services v0.1.0 needs (IDalamudPluginInterface, IPluginLog, IDataManager, IFramework, ISelfTestRegistry). - PluginHostFactory.Build: HostBuilder + AddDalamudLogging + the four service blocks (Dalamud services, Anvil singletons, ISelfTestStep collection, IHostedService init chain). Every registration uses an explicit factory lambda - the default activator only sees public ctors and Anvil follows the internal-sealed convention. - PluginLifecycle (IAsyncDisposable): owns Host.StartAsync, marshals the Host.Dispose call onto the framework thread, idempotency guard via Interlocked, ExceptionDispatchInfo.Capture preserves the original load-throw stack when a failure cascades. - Plugin (IAsyncDalamudPlugin): constructor injection of the five Dalamud services, builds the dependencies record, kicks off the host build, hands DisposeAsync to the lifecycle. - Hosting/RecipeDataLoadHostedService: dispatches LuminaRecipeAdapter .LoadInternal onto the framework thread on StartAsync. Lumina sheet reads have no documented thread safety; conservative default. - Hosting/SelfTestRegistrationHostedService: collects every ISelfTestStep registration from DI and hands them to ISelfTestRegistry.RegisterTestSteps once the host is up. - SelfTest/RecipeDataAdapterLoadStep: nine pass criteria per spec §4.1 (IsLoaded, RecipeCount > 0, ActionCount in 30..80, BuffsByKind.Count == 14, ConditionsByKind.Count == 11, Foods >= 30, Medicines >= 5, BasicSynthesis.RowIdByClassJob[8] == 100001, Cosmic-surface silent-degradation warning). Returns Waiting while the catalog is still loading. - Infrastructure/Logging trio: DalamudLogger maps Microsoft.Extensions.Logging levels to IPluginLog, the provider emits an Anvil bootstrap banner with a Forge-Bronze fingerprint on ctor, the extension wires the provider into the ILoggingBuilder via TryAddEnumerable.
This commit is contained in:
@@ -0,0 +1,49 @@
|
|||||||
|
// IHostedService adapter that runs the LuminaRecipeAdapter on the framework
|
||||||
|
// thread when the host starts. The dispatch is conservative - Lumina sheet
|
||||||
|
// reads have no documented thread-safety guarantee, and the CPU-bound work
|
||||||
|
// finishes well under a second, so framework-thread blocking is fine.
|
||||||
|
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Anvil.RecipeData.Internal;
|
||||||
|
using Dalamud.Plugin.Services;
|
||||||
|
using Microsoft.Extensions.Hosting;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace Anvil.Hosting;
|
||||||
|
|
||||||
|
internal sealed class RecipeDataLoadHostedService : IHostedService
|
||||||
|
{
|
||||||
|
private readonly IFramework _framework;
|
||||||
|
private readonly LuminaRecipeAdapter _adapter;
|
||||||
|
private readonly ILogger<RecipeDataLoadHostedService> _logger;
|
||||||
|
|
||||||
|
public RecipeDataLoadHostedService(
|
||||||
|
IFramework framework,
|
||||||
|
LuminaRecipeAdapter adapter,
|
||||||
|
ILogger<RecipeDataLoadHostedService> logger
|
||||||
|
)
|
||||||
|
{
|
||||||
|
_framework = framework;
|
||||||
|
_adapter = adapter;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task StartAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return _framework.RunOnFrameworkThread(() =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_adapter.LoadInternal(cancellationToken);
|
||||||
|
}
|
||||||
|
catch (System.Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Anvil RecipeData load failed");
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
// IHostedService adapter that hands every registered ISelfTestStep to
|
||||||
|
// Dalamud's ISelfTestRegistry once the host is up. Resolving the steps
|
||||||
|
// through DI keeps the plugin entry point free of static Plugin.X
|
||||||
|
// lookups - the DI container collects each step that was registered as
|
||||||
|
// IEnumerable<ISelfTestStep>.
|
||||||
|
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Dalamud.Plugin.SelfTest;
|
||||||
|
using Dalamud.Plugin.Services;
|
||||||
|
using Microsoft.Extensions.Hosting;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace Anvil.Hosting;
|
||||||
|
|
||||||
|
internal sealed class SelfTestRegistrationHostedService : IHostedService
|
||||||
|
{
|
||||||
|
private readonly ISelfTestRegistry _registry;
|
||||||
|
private readonly IEnumerable<ISelfTestStep> _steps;
|
||||||
|
private readonly ILogger<SelfTestRegistrationHostedService> _logger;
|
||||||
|
|
||||||
|
public SelfTestRegistrationHostedService(
|
||||||
|
ISelfTestRegistry registry,
|
||||||
|
IEnumerable<ISelfTestStep> steps,
|
||||||
|
ILogger<SelfTestRegistrationHostedService> logger
|
||||||
|
)
|
||||||
|
{
|
||||||
|
_registry = registry;
|
||||||
|
_steps = steps;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task StartAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var stepList = new List<ISelfTestStep>(_steps);
|
||||||
|
_registry.RegisterTestSteps(stepList);
|
||||||
|
_logger.LogInformation("Anvil registered {Count} self-test step(s)", stepList.Count);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
// Bridge from Microsoft.Extensions.Logging.ILogger to Dalamud's IPluginLog.
|
||||||
|
// One DalamudLogger instance per category, cached in DalamudLoggingProvider.
|
||||||
|
// Filtering is delegated to /xllog - every level falls through to the
|
||||||
|
// matching IPluginLog call, the plugin side stays stateless.
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using System.Text;
|
||||||
|
using Dalamud.Plugin.Services;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace Anvil.Infrastructure.Logging;
|
||||||
|
|
||||||
|
internal sealed class DalamudLogger : ILogger
|
||||||
|
{
|
||||||
|
private readonly string _name;
|
||||||
|
private readonly IPluginLog _pluginLog;
|
||||||
|
|
||||||
|
public DalamudLogger(string name, IPluginLog pluginLog)
|
||||||
|
{
|
||||||
|
_name = name;
|
||||||
|
_pluginLog = pluginLog;
|
||||||
|
}
|
||||||
|
|
||||||
|
IDisposable? ILogger.BeginScope<TState>(TState state) => default!;
|
||||||
|
|
||||||
|
public bool IsEnabled(LogLevel logLevel) => true;
|
||||||
|
|
||||||
|
public void Log<TState>(
|
||||||
|
LogLevel logLevel,
|
||||||
|
EventId eventId,
|
||||||
|
TState state,
|
||||||
|
Exception? exception,
|
||||||
|
Func<TState, Exception?, string> formatter
|
||||||
|
)
|
||||||
|
{
|
||||||
|
if (!IsEnabled(logLevel))
|
||||||
|
return;
|
||||||
|
|
||||||
|
if ((int)logLevel <= (int)LogLevel.Information)
|
||||||
|
{
|
||||||
|
_pluginLog.Information($"[{_name}] {{{(int)logLevel}}} {state}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
sb.Append($"[{_name}] {{{(int)logLevel}}} {state} {exception?.Message}");
|
||||||
|
if (!string.IsNullOrWhiteSpace(exception?.StackTrace))
|
||||||
|
sb.AppendLine(exception.StackTrace);
|
||||||
|
|
||||||
|
var inner = exception?.InnerException;
|
||||||
|
while (inner != null)
|
||||||
|
{
|
||||||
|
sb.AppendLine($"InnerException {inner}: {inner.Message}");
|
||||||
|
sb.AppendLine(inner.StackTrace);
|
||||||
|
inner = inner.InnerException;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (logLevel == LogLevel.Warning)
|
||||||
|
_pluginLog.Warning(sb.ToString());
|
||||||
|
else if (logLevel == LogLevel.Error)
|
||||||
|
_pluginLog.Error(sb.ToString());
|
||||||
|
else
|
||||||
|
_pluginLog.Fatal(sb.ToString());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
// ILoggerProvider that hands out DalamudLogger instances and emits the
|
||||||
|
// Anvil bootstrap banner once per plugin load. Category names are
|
||||||
|
// normalised (15-char left-pad or ellipsis trim) so /xllog columns stay
|
||||||
|
// aligned across services.
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using System.Collections.Concurrent;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
using Dalamud.Plugin.Services;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace Anvil.Infrastructure.Logging;
|
||||||
|
|
||||||
|
[ProviderAlias("Dalamud")]
|
||||||
|
internal sealed class DalamudLoggingProvider : ILoggerProvider
|
||||||
|
{
|
||||||
|
// Hellion Forge Bronze (#C2410C) mixed into the bootstrap fingerprint
|
||||||
|
// so a casual fork of the file still announces its origin in /xllog.
|
||||||
|
private const string AnvilMarker = "AnvilForgeBronzeC2410C";
|
||||||
|
|
||||||
|
private readonly ConcurrentDictionary<string, DalamudLogger> _loggers = new(
|
||||||
|
StringComparer.OrdinalIgnoreCase
|
||||||
|
);
|
||||||
|
|
||||||
|
private readonly IPluginLog _pluginLog;
|
||||||
|
|
||||||
|
public DalamudLoggingProvider(IPluginLog pluginLog)
|
||||||
|
{
|
||||||
|
_pluginLog = pluginLog;
|
||||||
|
EmitBootstrapBanner();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void EmitBootstrapBanner()
|
||||||
|
{
|
||||||
|
var version =
|
||||||
|
typeof(DalamudLoggingProvider).Assembly.GetName().Version?.ToString() ?? "0.0.0";
|
||||||
|
var fingerprint = ComputeFingerprint(version);
|
||||||
|
_pluginLog.Information(
|
||||||
|
$"Anvil DI-Logger bootstrap v{version} fingerprint={fingerprint} (Hellion Forge)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ComputeFingerprint(string version)
|
||||||
|
{
|
||||||
|
var seed = Encoding.UTF8.GetBytes($"{AnvilMarker}-{version}");
|
||||||
|
var hash = SHA256.HashData(seed);
|
||||||
|
var sb = new StringBuilder(8);
|
||||||
|
for (var i = 0; i < 4; i++)
|
||||||
|
sb.Append(hash[i].ToString("x2"));
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
public ILogger CreateLogger(string categoryName)
|
||||||
|
{
|
||||||
|
var catName = categoryName.Split(".", StringSplitOptions.RemoveEmptyEntries).Last();
|
||||||
|
if (catName.Length > 15)
|
||||||
|
catName = string.Concat(
|
||||||
|
catName.AsSpan(0, 6),
|
||||||
|
"...",
|
||||||
|
catName.AsSpan(catName.Length - 6, 6)
|
||||||
|
);
|
||||||
|
else
|
||||||
|
catName = catName.PadLeft(15);
|
||||||
|
|
||||||
|
return _loggers.GetOrAdd(catName, name => new DalamudLogger(name, _pluginLog));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_loggers.Clear();
|
||||||
|
GC.SuppressFinalize(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
// One-liner extension that wires DalamudLoggingProvider into the
|
||||||
|
// ILoggingBuilder. ClearProviders kills the default console output (no
|
||||||
|
// Dalamud plugin runs a console anyway); TryAddEnumerable keeps the
|
||||||
|
// registration idempotent if a caller adds it twice.
|
||||||
|
|
||||||
|
using Dalamud.Plugin.Services;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace Anvil.Infrastructure.Logging;
|
||||||
|
|
||||||
|
internal static class DalamudLoggingProviderExtensions
|
||||||
|
{
|
||||||
|
public static ILoggingBuilder AddDalamudLogging(
|
||||||
|
this ILoggingBuilder builder,
|
||||||
|
IPluginLog pluginLog
|
||||||
|
)
|
||||||
|
{
|
||||||
|
builder.ClearProviders();
|
||||||
|
builder.Services.TryAddEnumerable(
|
||||||
|
ServiceDescriptor.Singleton<ILoggerProvider, DalamudLoggingProvider>(
|
||||||
|
_ => new DalamudLoggingProvider(pluginLog)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
return builder;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
// Anvil plugin entry. Implements IAsyncDalamudPlugin so Dalamud awaits
|
||||||
|
// LoadAsync before considering the plugin loaded. The constructor wires
|
||||||
|
// the generic-host DI container synchronously - Dalamud's IoC fills the
|
||||||
|
// five Dalamud services as ctor args (Lightless pattern), the bundle is
|
||||||
|
// handed to PluginHostFactory, and PluginLifecycle takes it from there.
|
||||||
|
//
|
||||||
|
// v0.1.0 is intentionally thin. Module 02+ extends PluginHostFactory's
|
||||||
|
// services collection; this file does not need to change.
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Dalamud.Plugin;
|
||||||
|
using Dalamud.Plugin.Services;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
|
namespace Anvil;
|
||||||
|
|
||||||
|
public sealed class Plugin : IAsyncDalamudPlugin
|
||||||
|
{
|
||||||
|
private readonly PluginLifecycle _lifecycle;
|
||||||
|
|
||||||
|
public Plugin(
|
||||||
|
IDalamudPluginInterface pluginInterface,
|
||||||
|
IPluginLog pluginLog,
|
||||||
|
IDataManager dataManager,
|
||||||
|
IFramework framework,
|
||||||
|
ISelfTestRegistry selfTestRegistry
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var dependencies = new PluginHostDependencies(
|
||||||
|
pluginInterface,
|
||||||
|
pluginLog,
|
||||||
|
dataManager,
|
||||||
|
framework,
|
||||||
|
selfTestRegistry
|
||||||
|
);
|
||||||
|
|
||||||
|
var host = PluginHostFactory.Build(this, dependencies);
|
||||||
|
_lifecycle = host.Services.GetRequiredService<PluginLifecycle>();
|
||||||
|
_lifecycle.Host = host;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task LoadAsync(CancellationToken cancellationToken) =>
|
||||||
|
_lifecycle.LoadAsync(cancellationToken);
|
||||||
|
|
||||||
|
public async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
await _lifecycle.DisposeAsync().ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
// Bundle of every Dalamud service Anvil v0.1.0 needs. The Plugin entry
|
||||||
|
// constructs one of these from its [PluginService] properties and hands
|
||||||
|
// it to PluginHostFactory.Build. Keeping the bundle as a record means
|
||||||
|
// adding a service for module 02+ is a one-line schema change.
|
||||||
|
|
||||||
|
using Dalamud.Plugin;
|
||||||
|
using Dalamud.Plugin.Services;
|
||||||
|
|
||||||
|
namespace Anvil;
|
||||||
|
|
||||||
|
internal sealed record PluginHostDependencies(
|
||||||
|
IDalamudPluginInterface PluginInterface,
|
||||||
|
IPluginLog PluginLog,
|
||||||
|
IDataManager DataManager,
|
||||||
|
IFramework Framework,
|
||||||
|
ISelfTestRegistry SelfTestRegistry
|
||||||
|
);
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
// Builds the generic-host DI container for Anvil. v0.1.0 is intentionally
|
||||||
|
// thin - module 01 (RecipeData) plus the bootstrap glue. Module 02+ will
|
||||||
|
// extend the Block B / Block C sections, not replace them.
|
||||||
|
//
|
||||||
|
// Every service registration goes through a factory lambda. The default
|
||||||
|
// AddSingleton<T>() overload reflects public constructors, and Anvil
|
||||||
|
// follows the Goatcorp / Dalamud convention of internal-sealed types -
|
||||||
|
// the activator would throw on the first resolve otherwise (HellionChat
|
||||||
|
// v1.5.0 C0 smoke documented this). The lambda compiles inside Anvil's
|
||||||
|
// namespace where internal access works.
|
||||||
|
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using Anvil.Hosting;
|
||||||
|
using Anvil.Infrastructure.Logging;
|
||||||
|
using Anvil.RecipeData;
|
||||||
|
using Anvil.RecipeData.Internal;
|
||||||
|
using Anvil.SelfTest;
|
||||||
|
using Dalamud.Plugin;
|
||||||
|
using Dalamud.Plugin.SelfTest;
|
||||||
|
using Dalamud.Plugin.Services;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Hosting;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace Anvil;
|
||||||
|
|
||||||
|
internal static class PluginHostFactory
|
||||||
|
{
|
||||||
|
public static IHost Build(Plugin plugin, PluginHostDependencies dependencies)
|
||||||
|
{
|
||||||
|
return new HostBuilder()
|
||||||
|
.UseContentRoot(dependencies.PluginInterface.ConfigDirectory.FullName)
|
||||||
|
.ConfigureLogging(logging =>
|
||||||
|
{
|
||||||
|
logging.ClearProviders();
|
||||||
|
logging.AddDalamudLogging(dependencies.PluginLog);
|
||||||
|
logging.SetMinimumLevel(LogLevel.Trace);
|
||||||
|
})
|
||||||
|
.ConfigureServices(services => ConfigureServices(services, plugin, dependencies))
|
||||||
|
.Build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ConfigureServices(
|
||||||
|
IServiceCollection services,
|
||||||
|
Plugin plugin,
|
||||||
|
PluginHostDependencies dependencies
|
||||||
|
)
|
||||||
|
{
|
||||||
|
// ---- Block A: Dalamud services ----
|
||||||
|
services.AddSingleton(dependencies);
|
||||||
|
services.AddSingleton(dependencies.PluginInterface);
|
||||||
|
services.AddSingleton(dependencies.PluginLog);
|
||||||
|
services.AddSingleton(dependencies.DataManager);
|
||||||
|
services.AddSingleton(dependencies.Framework);
|
||||||
|
services.AddSingleton(dependencies.SelfTestRegistry);
|
||||||
|
|
||||||
|
// Self-reference. Lets services that genuinely need the plugin
|
||||||
|
// back-ref (later modules; v0.1.0 has none) reach it without a
|
||||||
|
// Plugin.Instance static.
|
||||||
|
services.AddSingleton(plugin);
|
||||||
|
|
||||||
|
services.AddSingleton<PluginLifecycle>(sp => new PluginLifecycle(
|
||||||
|
sp.GetRequiredService<IFramework>(),
|
||||||
|
sp.GetRequiredService<Plugin>()
|
||||||
|
));
|
||||||
|
|
||||||
|
// ---- Block B: Anvil singletons ----
|
||||||
|
services.AddSingleton<RecipeDataCatalog>(_ => new RecipeDataCatalog());
|
||||||
|
services.AddSingleton<LuminaRecipeAdapter>(sp => new LuminaRecipeAdapter(
|
||||||
|
sp.GetRequiredService<IDataManager>(),
|
||||||
|
sp.GetRequiredService<ILogger<LuminaRecipeAdapter>>(),
|
||||||
|
sp.GetRequiredService<RecipeDataCatalog>()
|
||||||
|
));
|
||||||
|
|
||||||
|
// ---- Block C: SelfTest steps (collected as IEnumerable<ISelfTestStep>) ----
|
||||||
|
services.AddSingleton<ISelfTestStep, RecipeDataAdapterLoadStep>(
|
||||||
|
sp => new RecipeDataAdapterLoadStep(
|
||||||
|
sp.GetRequiredService<RecipeDataCatalog>(),
|
||||||
|
sp.GetRequiredService<ILogger<RecipeDataAdapterLoadStep>>()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---- Block D: Hosted services (init order is registration order) ----
|
||||||
|
// RecipeData adapter runs first so the catalog is populated before
|
||||||
|
// the self-test registry sees its steps.
|
||||||
|
services.AddHostedService<RecipeDataLoadHostedService>(
|
||||||
|
sp => new RecipeDataLoadHostedService(
|
||||||
|
sp.GetRequiredService<IFramework>(),
|
||||||
|
sp.GetRequiredService<LuminaRecipeAdapter>(),
|
||||||
|
sp.GetRequiredService<ILogger<RecipeDataLoadHostedService>>()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
services.AddHostedService<SelfTestRegistrationHostedService>(
|
||||||
|
sp => new SelfTestRegistrationHostedService(
|
||||||
|
sp.GetRequiredService<ISelfTestRegistry>(),
|
||||||
|
sp.GetRequiredService<IEnumerable<ISelfTestStep>>(),
|
||||||
|
sp.GetRequiredService<ILogger<SelfTestRegistrationHostedService>>()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
// Owns the generic-host startup and the framework-thread shutdown for
|
||||||
|
// Anvil. Plugin.ctor builds the host synchronously and hands it to this
|
||||||
|
// class via the Host property so LoadAsync only deals with start-up
|
||||||
|
// orchestration. DisposeAsync trusts the container for service disposal
|
||||||
|
// (Container disposes singletons in reverse registration order on its own
|
||||||
|
// thread); we only marshal the Host.Dispose call to the framework thread
|
||||||
|
// because some services touch game state in their Dispose paths.
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using System.Runtime.ExceptionServices;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Dalamud.Plugin.Services;
|
||||||
|
using Microsoft.Extensions.Hosting;
|
||||||
|
|
||||||
|
namespace Anvil;
|
||||||
|
|
||||||
|
internal sealed class PluginLifecycle : IAsyncDisposable
|
||||||
|
{
|
||||||
|
private readonly IFramework _framework;
|
||||||
|
private readonly Plugin _plugin;
|
||||||
|
|
||||||
|
private int _disposeStarted;
|
||||||
|
private bool _hostStartRequested;
|
||||||
|
|
||||||
|
public PluginLifecycle(IFramework framework, Plugin plugin)
|
||||||
|
{
|
||||||
|
_framework = framework;
|
||||||
|
_plugin = plugin;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filled by Plugin.ctor immediately after PluginHostFactory.Build,
|
||||||
|
// before LoadAsync runs. Nullable to keep the property uninitialised
|
||||||
|
// when the DI container resolves PluginLifecycle.
|
||||||
|
public IHost? Host { get; set; }
|
||||||
|
|
||||||
|
public async Task LoadAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_hostStartRequested = true;
|
||||||
|
await Host!.StartAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await DisposeAsync().ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Swallow secondary dispose failure - the original load throw wins.
|
||||||
|
}
|
||||||
|
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
// Idempotency guard - Dalamud may fire DisposeAsync twice in a reload race.
|
||||||
|
if (Interlocked.Exchange(ref _disposeStarted, 1) != 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
Exception? failure = null;
|
||||||
|
|
||||||
|
if (_hostStartRequested && Host is not null)
|
||||||
|
failure = await CaptureFailureAsync(failure, () => Host.StopAsync())
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
failure = await DisposeHostOnFrameworkThreadAsync(failure).ConfigureAwait(false);
|
||||||
|
|
||||||
|
ThrowIfFailed(failure);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<Exception?> DisposeHostOnFrameworkThreadAsync(Exception? failure)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _framework
|
||||||
|
.RunOnFrameworkThread(() =>
|
||||||
|
{
|
||||||
|
failure = CaptureFailure(failure, () => Host?.Dispose());
|
||||||
|
})
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
failure ??= ex;
|
||||||
|
}
|
||||||
|
|
||||||
|
return failure;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Exception? CaptureFailure(Exception? failure, Action action)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
action();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
failure ??= ex;
|
||||||
|
}
|
||||||
|
|
||||||
|
return failure;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async ValueTask<Exception?> CaptureFailureAsync(
|
||||||
|
Exception? failure,
|
||||||
|
Func<Task> action
|
||||||
|
)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await action().ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
failure ??= ex;
|
||||||
|
}
|
||||||
|
|
||||||
|
return failure;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ThrowIfFailed(Exception? failure)
|
||||||
|
{
|
||||||
|
if (failure is not null)
|
||||||
|
ExceptionDispatchInfo.Capture(failure).Throw();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
// /xlperf verification that the LuminaRecipeAdapter populated the catalog.
|
||||||
|
// All nine pass criteria come from spec §4.1. The step is patient: while
|
||||||
|
// the adapter is still loading (IsLoaded == false) the step returns
|
||||||
|
// Waiting; once the catalog is populated, every criterion is checked in
|
||||||
|
// a single frame.
|
||||||
|
|
||||||
|
using System.Linq;
|
||||||
|
using Anvil.RecipeData;
|
||||||
|
using Dalamud.Plugin.SelfTest;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace Anvil.SelfTest;
|
||||||
|
|
||||||
|
internal sealed class RecipeDataAdapterLoadStep : ISelfTestStep
|
||||||
|
{
|
||||||
|
// Spec §4.1 criterion #8: BasicSynthesis CRP variant Lumina RowId.
|
||||||
|
// Cross-verified against ffxiv-datamining CraftAction.csv row 100001.
|
||||||
|
private const uint BasicSynthesisCrpRowId = 100001;
|
||||||
|
private const uint CarpenterClassJobId = 8;
|
||||||
|
|
||||||
|
private readonly RecipeDataCatalog _catalog;
|
||||||
|
private readonly ILogger<RecipeDataAdapterLoadStep> _logger;
|
||||||
|
|
||||||
|
public RecipeDataAdapterLoadStep(
|
||||||
|
RecipeDataCatalog catalog,
|
||||||
|
ILogger<RecipeDataAdapterLoadStep> logger
|
||||||
|
)
|
||||||
|
{
|
||||||
|
_catalog = catalog;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string Name =>
|
||||||
|
Localization.AnvilStrings.SelfTest_RecipeDataAdapterLoad_Name
|
||||||
|
?? "Anvil: RecipeData adapter load";
|
||||||
|
|
||||||
|
public SelfTestStepResult RunStep()
|
||||||
|
{
|
||||||
|
if (!_catalog.IsLoaded)
|
||||||
|
return SelfTestStepResult.Waiting;
|
||||||
|
|
||||||
|
var failures = 0;
|
||||||
|
|
||||||
|
// #1: IsLoaded must be true. Implied by the early-return above; the
|
||||||
|
// check stays explicit so the criterion shows up in the log on a
|
||||||
|
// load that flipped the flag without populating anything.
|
||||||
|
if (!_catalog.IsLoaded)
|
||||||
|
{
|
||||||
|
_logger.LogError("RecipeDataCatalog.IsLoaded is false after StartAsync.");
|
||||||
|
failures++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// #2: Recipe count sanity floor.
|
||||||
|
if (_catalog.RecipeCount <= 0)
|
||||||
|
{
|
||||||
|
_logger.LogError(
|
||||||
|
"RecipeDataCatalog.RecipeCount is {Count}; expected > 0.",
|
||||||
|
_catalog.RecipeCount
|
||||||
|
);
|
||||||
|
failures++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// #3: Action count in the catalog. Spec says 30-80 as a generous
|
||||||
|
// range; tightening will follow once /xldev pins the exact patch
|
||||||
|
// value.
|
||||||
|
if (_catalog.ActionCount is < 30 or > 80)
|
||||||
|
{
|
||||||
|
_logger.LogError(
|
||||||
|
"RecipeDataCatalog.ActionCount is {Count}; expected 30 <= n <= 80.",
|
||||||
|
_catalog.ActionCount
|
||||||
|
);
|
||||||
|
failures++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// #4: Buff catalog count exact.
|
||||||
|
if (_catalog.BuffsByKind.Count != 14)
|
||||||
|
{
|
||||||
|
_logger.LogError(
|
||||||
|
"RecipeDataCatalog.BuffsByKind.Count is {Count}; expected 14.",
|
||||||
|
_catalog.BuffsByKind.Count
|
||||||
|
);
|
||||||
|
failures++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// #5: Condition catalog count exact.
|
||||||
|
if (_catalog.ConditionsByKind.Count != 11)
|
||||||
|
{
|
||||||
|
_logger.LogError(
|
||||||
|
"RecipeDataCatalog.ConditionsByKind.Count is {Count}; expected 11.",
|
||||||
|
_catalog.ConditionsByKind.Count
|
||||||
|
);
|
||||||
|
failures++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// #6: Food sanity floor.
|
||||||
|
if (_catalog.Foods.Count < 30)
|
||||||
|
{
|
||||||
|
_logger.LogError(
|
||||||
|
"RecipeDataCatalog.Foods.Count is {Count}; expected >= 30.",
|
||||||
|
_catalog.Foods.Count
|
||||||
|
);
|
||||||
|
failures++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// #7: Medicine sanity floor.
|
||||||
|
if (_catalog.Medicines.Count < 5)
|
||||||
|
{
|
||||||
|
_logger.LogError(
|
||||||
|
"RecipeDataCatalog.Medicines.Count is {Count}; expected >= 5.",
|
||||||
|
_catalog.Medicines.Count
|
||||||
|
);
|
||||||
|
failures++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// #8: BasicSynthesis CRP RowId sanity probe.
|
||||||
|
if (!_catalog.ActionsByKind.TryGetValue(AnvilActionKind.BasicSynthesis, out var basic))
|
||||||
|
{
|
||||||
|
_logger.LogError("ActionsByKind[BasicSynthesis] is missing.");
|
||||||
|
failures++;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
if (basic.RowIdByClassJob.Count != 8)
|
||||||
|
{
|
||||||
|
_logger.LogError(
|
||||||
|
"BasicSynthesis.RowIdByClassJob.Count is {Count}; expected 8.",
|
||||||
|
basic.RowIdByClassJob.Count
|
||||||
|
);
|
||||||
|
failures++;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!basic.RowIdByClassJob.TryGetValue(CarpenterClassJobId, out var crpRowId)
|
||||||
|
|| crpRowId != BasicSynthesisCrpRowId
|
||||||
|
)
|
||||||
|
{
|
||||||
|
_logger.LogError(
|
||||||
|
"BasicSynthesis.RowIdByClassJob[8] is {Actual}; expected {Expected}.",
|
||||||
|
basic.RowIdByClassJob.TryGetValue(CarpenterClassJobId, out var probe)
|
||||||
|
? probe
|
||||||
|
: 0,
|
||||||
|
BasicSynthesisCrpRowId
|
||||||
|
);
|
||||||
|
failures++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// #9: Cosmic-surface sanity. v0.1.0 ships with SH-15 option B (all
|
||||||
|
// MissionHas* flags forced false), so the WARN is expected whenever
|
||||||
|
// any Cosmic recipe is in the catalog. The step still returns Pass
|
||||||
|
// because the catalog itself is not broken - the Cosmic sub-surface
|
||||||
|
// is intentionally disabled.
|
||||||
|
var hasCosmicRecipe = _catalog.RecipesById.Values.Any(r => r.IsCosmic);
|
||||||
|
if (hasCosmicRecipe)
|
||||||
|
{
|
||||||
|
var anyMissionFlag = _catalog
|
||||||
|
.RecipesById.Values.Where(r => r.IsCosmic)
|
||||||
|
.Any(r => r.MissionHasMaterialMiracle || r.MissionHasSteadyHand);
|
||||||
|
if (!anyMissionFlag)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(
|
||||||
|
"Cosmic recipes loaded, but no MissionHas* flags resolved - "
|
||||||
|
+ "Cosmic-Surface silent-degraded. Expected under SH-15 option B (v0.1.0)."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return failures == 0 ? SelfTestStepResult.Pass : SelfTestStepResult.Fail;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void CleanUp() { }
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user