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