feat(plugins): complete Campaign LA5 cross-host hosting
This commit is contained in:
parent
6c4cd2bbc6
commit
95f4be94db
26 changed files with 1630 additions and 99 deletions
|
|
@ -28,6 +28,7 @@
|
||||||
<Project Path="tests/AcDream.Headless.Tests/AcDream.Headless.Tests.csproj" />
|
<Project Path="tests/AcDream.Headless.Tests/AcDream.Headless.Tests.csproj" />
|
||||||
<Project Path="tests/AcDream.Launcher.Core.Tests/AcDream.Launcher.Core.Tests.csproj" />
|
<Project Path="tests/AcDream.Launcher.Core.Tests/AcDream.Launcher.Core.Tests.csproj" />
|
||||||
<Project Path="tests/AcDream.Platform.Tests/AcDream.Platform.Tests.csproj" />
|
<Project Path="tests/AcDream.Platform.Tests/AcDream.Platform.Tests.csproj" />
|
||||||
|
<Project Path="tests/AcDream.Plugin.Tests.Fixtures.HostPlugin/AcDream.Plugin.Tests.Fixtures.HostPlugin.csproj" />
|
||||||
<Project Path="tests/AcDream.Runtime.Tests/AcDream.Runtime.Tests.csproj" />
|
<Project Path="tests/AcDream.Runtime.Tests/AcDream.Runtime.Tests.csproj" />
|
||||||
<Project Path="tests/AcDream.UI.Abstractions.Tests/AcDream.UI.Abstractions.Tests.csproj" />
|
<Project Path="tests/AcDream.UI.Abstractions.Tests/AcDream.UI.Abstractions.Tests.csproj" />
|
||||||
</Folder>
|
</Folder>
|
||||||
|
|
|
||||||
|
|
@ -120,7 +120,16 @@ handlers and controllers translate those intents to `WorldSession`; panels
|
||||||
never inspect or construct wire messages.
|
never inspect or construct wire messages.
|
||||||
Plugins register retained gameplay markup through the BCL-only
|
Plugins register retained gameplay markup through the BCL-only
|
||||||
`AcDream.Plugin.Abstractions.IUiRegistry`; they do not import App or
|
`AcDream.Plugin.Abstractions.IUiRegistry`; they do not import App or
|
||||||
presentation assemblies. Core `SelectionState` is the sole selected-object owner for world,
|
presentation assemblies. `IPluginHost.HasUi` is the explicit capability edge:
|
||||||
|
the graphical host supplies its retained registry, while no-window hosts
|
||||||
|
return `false` and the BCL-only `NoOpUiRegistry`, which retains no plugin
|
||||||
|
binding. Both hosts use Core's session-scoped discovery/lifetime orchestrator
|
||||||
|
and the same config allow-list semantics (absent loads all; explicit empty
|
||||||
|
loads none). The headless adapter projects entity snapshots on demand from the
|
||||||
|
canonical Runtime view, subscribes to Runtime's ordered events, and borrows the
|
||||||
|
exact Runtime selection owner; it does not mirror gameplay state.
|
||||||
|
|
||||||
|
Core `SelectionState` is the sole selected-object owner for world,
|
||||||
radar, inventory, paperdoll, toolbar, use/examine consumers, and plugins;
|
radar, inventory, paperdoll, toolbar, use/examine consumers, and plugins;
|
||||||
`IPluginHost.Selection` exposes that same state and retail-style old/new callback.
|
`IPluginHost.Selection` exposes that same state and retail-style old/new callback.
|
||||||
Temporary pointer modes are separate App orchestration in `InteractionState` and
|
Temporary pointer modes are separate App orchestration in `InteractionState` and
|
||||||
|
|
@ -174,6 +183,9 @@ parallel window-lifecycle map.
|
||||||
```
|
```
|
||||||
src/
|
src/
|
||||||
AcDream.Core/ Layer 2-4: no Vulkan, no Silk.NET, pure logic
|
AcDream.Core/ Layer 2-4: no Vulkan, no Silk.NET, pure logic
|
||||||
|
Plugins/
|
||||||
|
PluginSession.cs -> shared per-host allow-list, failure isolation,
|
||||||
|
status outcome, and collectible ALC lifetime
|
||||||
Physics/
|
Physics/
|
||||||
PhysicsBody.cs -> body state / integration foundation (done)
|
PhysicsBody.cs -> body state / integration foundation (done)
|
||||||
CollisionPrimitives.cs -> retail primitive helpers (partial, active)
|
CollisionPrimitives.cs -> retail primitive helpers (partial, active)
|
||||||
|
|
@ -288,6 +300,8 @@ src/
|
||||||
Configuration/ -> strict versioned process/session config
|
Configuration/ -> strict versioned process/session config
|
||||||
Credentials/ -> redacted env/stdin/owner-only-file providers
|
Credentials/ -> redacted env/stdin/owner-only-file providers
|
||||||
Hosting/ -> one GameRuntime/session/lease/policy lifetime
|
Hosting/ -> one GameRuntime/session/lease/policy lifetime
|
||||||
|
Plugins/ -> no-window IPluginHost borrowing Runtime/Core;
|
||||||
|
BCL no-op UI and per-session plugin lifetime
|
||||||
Policies/ -> typed Runtime-view/command consumers
|
Policies/ -> typed Runtime-view/command consumers
|
||||||
-> references Runtime only; no presentation/backend package
|
-> references Runtime only; no presentation/backend package
|
||||||
-> Slice K complete: portable single/multi-session production host,
|
-> Slice K complete: portable single/multi-session production host,
|
||||||
|
|
@ -298,6 +312,7 @@ src/
|
||||||
AcDream.Plugin.Abstractions/ Layer 5: plugin interfaces
|
AcDream.Plugin.Abstractions/ Layer 5: plugin interfaces
|
||||||
IAcDreamPlugin.cs -> done
|
IAcDreamPlugin.cs -> done
|
||||||
IPluginHost.cs -> done
|
IPluginHost.cs -> done
|
||||||
|
IUiRegistry.cs -> capability-aware retained/no-op UI contract
|
||||||
IGameState.cs -> done
|
IGameState.cs -> done
|
||||||
IEvents.cs -> done
|
IEvents.cs -> done
|
||||||
ISelectionService.cs -> done
|
ISelectionService.cs -> done
|
||||||
|
|
@ -352,6 +367,7 @@ src/
|
||||||
PlayerMovementController.cs -> active movement driver
|
PlayerMovementController.cs -> active movement driver
|
||||||
Plugins/
|
Plugins/
|
||||||
AppPluginHost.cs -> done
|
AppPluginHost.cs -> done
|
||||||
|
GraphicalPluginSession.cs -> thin shared-session/root/status adapter
|
||||||
```
|
```
|
||||||
|
|
||||||
The 4B2 production SetPosition routes and shared local-controller body remain
|
The 4B2 production SetPosition routes and shared local-controller body remain
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ public sealed class AppPluginHost : IPluginHost
|
||||||
Ui = ui;
|
Ui = ui;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public bool HasUi => true;
|
||||||
public IPluginLogger Log { get; }
|
public IPluginLogger Log { get; }
|
||||||
public IGameState State { get; }
|
public IGameState State { get; }
|
||||||
public IEvents Events { get; }
|
public IEvents Events { get; }
|
||||||
|
|
|
||||||
78
src/AcDream.App/Plugins/GraphicalPluginSession.cs
Normal file
78
src/AcDream.App/Plugins/GraphicalPluginSession.cs
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
using AcDream.Core.Plugins;
|
||||||
|
using AcDream.Platform;
|
||||||
|
using AcDream.Plugin.Abstractions;
|
||||||
|
using AcDream.Runtime.Session;
|
||||||
|
|
||||||
|
namespace AcDream.App.Plugins;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Graphical-host composition for one plugin set. The shared
|
||||||
|
/// <see cref="PluginSession"/> owns discovery and collectible lifetimes; this
|
||||||
|
/// adapter supplies the graphical roots and translates outcomes into the
|
||||||
|
/// launcher status stream.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class GraphicalPluginSession : IDisposable
|
||||||
|
{
|
||||||
|
private readonly PluginSession _plugins;
|
||||||
|
|
||||||
|
private GraphicalPluginSession(PluginSession plugins)
|
||||||
|
{
|
||||||
|
_plugins = plugins;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal int LoadedCount => _plugins.LoadedCount;
|
||||||
|
|
||||||
|
internal IReadOnlyList<WeakReference> CaptureLoadContextWeakReferences() =>
|
||||||
|
_plugins.CaptureLoadContextWeakReferences();
|
||||||
|
|
||||||
|
internal static GraphicalPluginSession Start(
|
||||||
|
ApplicationPathSet paths,
|
||||||
|
IReadOnlyList<string>? allowList,
|
||||||
|
string sessionId,
|
||||||
|
IPluginHost host,
|
||||||
|
SessionStatusWriter statusWriter)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(paths);
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(sessionId);
|
||||||
|
ArgumentNullException.ThrowIfNull(host);
|
||||||
|
ArgumentNullException.ThrowIfNull(statusWriter);
|
||||||
|
|
||||||
|
var plugins = new PluginSession(
|
||||||
|
host,
|
||||||
|
status => Report(statusWriter, sessionId, status));
|
||||||
|
try
|
||||||
|
{
|
||||||
|
plugins.Start(
|
||||||
|
[
|
||||||
|
Path.Combine(AppContext.BaseDirectory, "plugins"),
|
||||||
|
paths.PluginsDirectory,
|
||||||
|
],
|
||||||
|
allowList);
|
||||||
|
return new GraphicalPluginSession(plugins);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
plugins.Dispose();
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose() => _plugins.Dispose();
|
||||||
|
|
||||||
|
private static void Report(
|
||||||
|
SessionStatusWriter writer,
|
||||||
|
string sessionId,
|
||||||
|
PluginSessionStatus status)
|
||||||
|
{
|
||||||
|
if (status.Kind == PluginSessionStatusKind.Loaded)
|
||||||
|
{
|
||||||
|
writer.PluginLoaded(sessionId, status.Plugin);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
writer.PluginFailed(
|
||||||
|
sessionId,
|
||||||
|
status.Plugin,
|
||||||
|
status.Error ?? "plugin failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -4,7 +4,6 @@ using AcDream.App.Credentials;
|
||||||
using AcDream.App.Plugins;
|
using AcDream.App.Plugins;
|
||||||
using AcDream.App.Platform;
|
using AcDream.App.Platform;
|
||||||
using AcDream.App.Rendering;
|
using AcDream.App.Rendering;
|
||||||
using AcDream.Core.Plugins;
|
|
||||||
using AcDream.Platform;
|
using AcDream.Platform;
|
||||||
using Serilog;
|
using Serilog;
|
||||||
|
|
||||||
|
|
@ -162,76 +161,15 @@ var host = new AppPluginHost(
|
||||||
worldEvents,
|
worldEvents,
|
||||||
window.Selection,
|
window.Selection,
|
||||||
uiRegistry);
|
uiRegistry);
|
||||||
|
using var pluginSession = GraphicalPluginSession.Start(
|
||||||
var loaded = new List<LoadedPlugin>();
|
applicationPaths,
|
||||||
var loadedPluginIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
runtimeOptions.Plugins,
|
||||||
StringComparer pathComparer =
|
runtimeOptions.SessionId ?? "app",
|
||||||
graphicalPlatform.OperatingSystem
|
host,
|
||||||
== GraphicalHostOperatingSystem.Windows
|
window.StatusWriter);
|
||||||
? StringComparer.OrdinalIgnoreCase
|
|
||||||
: StringComparer.Ordinal;
|
|
||||||
string[] pluginRoots =
|
|
||||||
[
|
|
||||||
.. new[]
|
|
||||||
{
|
|
||||||
Path.Combine(AppContext.BaseDirectory, "plugins"),
|
|
||||||
applicationPaths.PluginsDirectory,
|
|
||||||
}.Distinct(pathComparer),
|
|
||||||
];
|
|
||||||
|
|
||||||
foreach (string pluginsDir in pluginRoots)
|
|
||||||
{
|
|
||||||
Log.Information("scanning plugins in {PluginsDir}", pluginsDir);
|
|
||||||
foreach (var result in PluginDiscovery.Scan(pluginsDir))
|
|
||||||
{
|
|
||||||
if (!result.Success)
|
|
||||||
{
|
|
||||||
Log.Warning(
|
|
||||||
"plugin discovery failed for {Dir}: {Error}",
|
|
||||||
result.PluginDirectory,
|
|
||||||
result.Error);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (loadedPluginIds.Contains(result.Manifest!.Id))
|
|
||||||
{
|
|
||||||
Log.Warning(
|
|
||||||
"skipping duplicate plugin id {Id} from {Dir}",
|
|
||||||
result.Manifest.Id,
|
|
||||||
result.PluginDirectory);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
var loadResult = PluginLoader.Load(
|
|
||||||
result.PluginDirectory,
|
|
||||||
result.Manifest,
|
|
||||||
host);
|
|
||||||
if (!loadResult.Success)
|
|
||||||
{
|
|
||||||
Log.Warning(
|
|
||||||
"plugin load failed for {Id}: {Error}",
|
|
||||||
result.Manifest.Id,
|
|
||||||
loadResult.Error);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
loadedPluginIds.Add(result.Manifest.Id);
|
|
||||||
loaded.Add(loadResult);
|
|
||||||
Log.Information(
|
|
||||||
"loaded plugin {Id} ({DisplayName})",
|
|
||||||
result.Manifest.Id,
|
|
||||||
result.Manifest.DisplayName);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
foreach (var plugin in loaded)
|
|
||||||
{
|
|
||||||
try { plugin.Plugin!.Enable(); }
|
|
||||||
catch (Exception ex) { Log.Error(ex, "plugin enable failed: {Id}", plugin.Manifest.Id); }
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
window.Run();
|
window.Run();
|
||||||
|
|
@ -244,11 +182,7 @@ try
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
foreach (var plugin in loaded)
|
pluginSession.Dispose();
|
||||||
{
|
|
||||||
try { plugin.Plugin!.Disable(); }
|
|
||||||
catch (Exception ex) { Log.Error(ex, "plugin disable failed: {Id}", plugin.Manifest.Id); }
|
|
||||||
}
|
|
||||||
Log.CloseAndFlush();
|
Log.CloseAndFlush();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -362,6 +362,7 @@ public sealed class GameWindow :
|
||||||
private RuntimeActionState _runtimeActions => _runtime.ActionOwner;
|
private RuntimeActionState _runtimeActions => _runtime.ActionOwner;
|
||||||
public AcDream.Core.Selection.SelectionState Selection =>
|
public AcDream.Core.Selection.SelectionState Selection =>
|
||||||
_runtimeActions.Selection;
|
_runtimeActions.Selection;
|
||||||
|
internal SessionStatusWriter StatusWriter => _statusWriter;
|
||||||
public AcDream.Core.Chat.ChatLog Chat => _runtimeCommunication.Chat;
|
public AcDream.Core.Chat.ChatLog Chat => _runtimeCommunication.Chat;
|
||||||
public AcDream.Core.Chat.TurbineChatState TurbineChat =>
|
public AcDream.Core.Chat.TurbineChatState TurbineChat =>
|
||||||
_runtimeCommunication.TurbineChat;
|
_runtimeCommunication.TurbineChat;
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,10 @@ public static class PluginLoader
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static LoadedPlugin Load(string pluginDirectory, PluginManifest manifest, IPluginHost host)
|
public static LoadedPlugin Load(string pluginDirectory, PluginManifest manifest, IPluginHost host)
|
||||||
{
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(pluginDirectory);
|
||||||
|
ArgumentNullException.ThrowIfNull(manifest);
|
||||||
|
ArgumentNullException.ThrowIfNull(host);
|
||||||
|
|
||||||
var dllPath = Path.Combine(pluginDirectory, manifest.EntryDll);
|
var dllPath = Path.Combine(pluginDirectory, manifest.EntryDll);
|
||||||
if (!File.Exists(dllPath))
|
if (!File.Exists(dllPath))
|
||||||
return new LoadedPlugin(
|
return new LoadedPlugin(
|
||||||
|
|
@ -22,9 +26,11 @@ public static class PluginLoader
|
||||||
LoadContext: null,
|
LoadContext: null,
|
||||||
Error: new FileNotFoundException($"entry dll not found: {dllPath}", dllPath));
|
Error: new FileNotFoundException($"entry dll not found: {dllPath}", dllPath));
|
||||||
|
|
||||||
|
PluginAssemblyLoadContext? alc = null;
|
||||||
|
IAcDreamPlugin? instance = null;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var alc = new PluginAssemblyLoadContext(pluginDirectory, dllPath);
|
alc = new PluginAssemblyLoadContext(pluginDirectory, dllPath);
|
||||||
var asm = alc.LoadFromAssemblyPath(dllPath);
|
var asm = alc.LoadFromAssemblyPath(dllPath);
|
||||||
|
|
||||||
IEnumerable<Type> types;
|
IEnumerable<Type> types;
|
||||||
|
|
@ -41,19 +47,29 @@ public static class PluginLoader
|
||||||
.FirstOrDefault(t => !t.IsAbstract && typeof(IAcDreamPlugin).IsAssignableFrom(t));
|
.FirstOrDefault(t => !t.IsAbstract && typeof(IAcDreamPlugin).IsAssignableFrom(t));
|
||||||
|
|
||||||
if (pluginType is null)
|
if (pluginType is null)
|
||||||
|
{
|
||||||
|
alc.Unload();
|
||||||
return new LoadedPlugin(
|
return new LoadedPlugin(
|
||||||
manifest,
|
manifest,
|
||||||
Plugin: null,
|
Plugin: null,
|
||||||
LoadContext: null,
|
LoadContext: null,
|
||||||
Error: new InvalidOperationException(
|
Error: new InvalidOperationException(
|
||||||
$"no IAcDreamPlugin implementation found in {manifest.EntryDll}"));
|
$"no IAcDreamPlugin implementation found in {manifest.EntryDll}"));
|
||||||
|
}
|
||||||
|
|
||||||
var instance = (IAcDreamPlugin)Activator.CreateInstance(pluginType)!;
|
instance = (IAcDreamPlugin)Activator.CreateInstance(pluginType)!;
|
||||||
instance.Initialize(host);
|
instance.Initialize(host);
|
||||||
return new LoadedPlugin(manifest, instance, alc, Error: null);
|
return new LoadedPlugin(manifest, instance, alc, Error: null);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
// Initialize may have attached host callbacks before it failed.
|
||||||
|
// Give that partial instance the same best-effort cleanup chance
|
||||||
|
// as an Enable failure before releasing the collectible context.
|
||||||
|
try { instance?.Disable(); }
|
||||||
|
catch { }
|
||||||
|
try { alc?.Unload(); }
|
||||||
|
catch { }
|
||||||
return new LoadedPlugin(manifest, Plugin: null, LoadContext: null, Error: ex);
|
return new LoadedPlugin(manifest, Plugin: null, LoadContext: null, Error: ex);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
361
src/AcDream.Core/Plugins/PluginSession.cs
Normal file
361
src/AcDream.Core/Plugins/PluginSession.cs
Normal file
|
|
@ -0,0 +1,361 @@
|
||||||
|
using AcDream.Plugin.Abstractions;
|
||||||
|
|
||||||
|
namespace AcDream.Core.Plugins;
|
||||||
|
|
||||||
|
public enum PluginSessionStatusKind
|
||||||
|
{
|
||||||
|
Loaded,
|
||||||
|
Failed,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Final startup outcome for one configured plugin id. Hosts translate these
|
||||||
|
/// outcomes into their own diagnostics and the Campaign LA status stream.
|
||||||
|
/// </summary>
|
||||||
|
public readonly record struct PluginSessionStatus(
|
||||||
|
string Plugin,
|
||||||
|
PluginSessionStatusKind Kind,
|
||||||
|
string? Error = null);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One host/session-scoped plugin lifetime. Discovery, allow-listing,
|
||||||
|
/// initialize/enable, failure isolation, reverse-order disable, and collectible
|
||||||
|
/// load-context release are shared by graphical and no-window hosts so their
|
||||||
|
/// configured plugin-set semantics cannot drift.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class PluginSession : IDisposable
|
||||||
|
{
|
||||||
|
private readonly IPluginHost _host;
|
||||||
|
private readonly Action<PluginSessionStatus>? _report;
|
||||||
|
private readonly List<LoadedPlugin> _loaded = [];
|
||||||
|
private bool _started;
|
||||||
|
private bool _disposed;
|
||||||
|
|
||||||
|
public PluginSession(
|
||||||
|
IPluginHost host,
|
||||||
|
Action<PluginSessionStatus>? report = null)
|
||||||
|
{
|
||||||
|
_host = host ?? throw new ArgumentNullException(nameof(host));
|
||||||
|
_report = report;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int LoadedCount => _loaded.Count;
|
||||||
|
|
||||||
|
public IReadOnlyList<string> LoadedPluginIds =>
|
||||||
|
_loaded.Select(static plugin => plugin.Manifest.Id).ToArray();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Discovers and starts the configured set exactly once. A
|
||||||
|
/// <see langword="null"/> allow-list loads every discovered id; an explicit
|
||||||
|
/// empty list loads none. Matching and duplicate-id handling are
|
||||||
|
/// ordinal-ignore-case on every operating system because plugin ids are
|
||||||
|
/// logical identifiers, not paths.
|
||||||
|
/// </summary>
|
||||||
|
public void Start(
|
||||||
|
IEnumerable<string> pluginRoots,
|
||||||
|
IReadOnlyList<string>? allowList)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(pluginRoots);
|
||||||
|
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||||
|
if (_started)
|
||||||
|
throw new InvalidOperationException("The plugin session has already started.");
|
||||||
|
_started = true;
|
||||||
|
|
||||||
|
string[] roots = DistinctRoots(pluginRoots);
|
||||||
|
string[]? requested = allowList is null
|
||||||
|
? null
|
||||||
|
: allowList
|
||||||
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToArray();
|
||||||
|
if (requested is { Length: 0 })
|
||||||
|
return;
|
||||||
|
|
||||||
|
var candidates = new Dictionary<string, List<PluginDiscoveryResult>>(
|
||||||
|
StringComparer.OrdinalIgnoreCase);
|
||||||
|
var errors = new Dictionary<string, List<Exception>>(
|
||||||
|
StringComparer.OrdinalIgnoreCase);
|
||||||
|
var discoveredOrder = new List<string>();
|
||||||
|
HashSet<string>? requestedSet = requested is null
|
||||||
|
? null
|
||||||
|
: new HashSet<string>(requested, StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
foreach (string root in roots)
|
||||||
|
{
|
||||||
|
IReadOnlyList<PluginDiscoveryResult> results;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
results = PluginDiscovery.Scan(root);
|
||||||
|
}
|
||||||
|
catch (Exception error) when (IsDiscoveryFailure(error))
|
||||||
|
{
|
||||||
|
SafeLog(
|
||||||
|
static (log, message, exception) =>
|
||||||
|
log.Error(message, exception),
|
||||||
|
$"plugin discovery failed for root '{root}'",
|
||||||
|
error);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (PluginDiscoveryResult result in results)
|
||||||
|
{
|
||||||
|
if (!result.Success)
|
||||||
|
{
|
||||||
|
string directoryId = Path.GetFileName(
|
||||||
|
Path.TrimEndingDirectorySeparator(result.PluginDirectory));
|
||||||
|
if (string.IsNullOrWhiteSpace(directoryId)
|
||||||
|
|| (requestedSet is not null
|
||||||
|
&& !requestedSet.Contains(directoryId)))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
AddOrdered(discoveredOrder, directoryId);
|
||||||
|
AddError(
|
||||||
|
errors,
|
||||||
|
directoryId,
|
||||||
|
result.Error ?? new InvalidOperationException(
|
||||||
|
"plugin discovery failed"));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
string id = result.Manifest!.Id;
|
||||||
|
if (requestedSet is not null && !requestedSet.Contains(id))
|
||||||
|
continue;
|
||||||
|
AddOrdered(discoveredOrder, id);
|
||||||
|
if (!candidates.TryGetValue(id, out List<PluginDiscoveryResult>? list))
|
||||||
|
{
|
||||||
|
list = [];
|
||||||
|
candidates.Add(id, list);
|
||||||
|
}
|
||||||
|
list.Add(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
IEnumerable<string> loadOrder = requested is null
|
||||||
|
? discoveredOrder
|
||||||
|
: requested;
|
||||||
|
foreach (string id in loadOrder)
|
||||||
|
LoadOne(id, candidates, errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Test/diagnostic observation of the exact collectible contexts currently
|
||||||
|
/// owned by this session. The returned weak references do not delay unload.
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyList<WeakReference> CaptureLoadContextWeakReferences() =>
|
||||||
|
_loaded
|
||||||
|
.Select(static plugin => new WeakReference(plugin.LoadContext!))
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (_disposed)
|
||||||
|
return;
|
||||||
|
_disposed = true;
|
||||||
|
|
||||||
|
for (int index = _loaded.Count - 1; index >= 0; index--)
|
||||||
|
{
|
||||||
|
LoadedPlugin loaded = _loaded[index];
|
||||||
|
try
|
||||||
|
{
|
||||||
|
loaded.Plugin!.Disable();
|
||||||
|
}
|
||||||
|
catch (Exception error)
|
||||||
|
{
|
||||||
|
SafeLog(
|
||||||
|
static (log, message, exception) =>
|
||||||
|
log.Error(message, exception),
|
||||||
|
$"plugin disable failed: {loaded.Manifest.Id}",
|
||||||
|
error);
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
loaded.LoadContext!.Unload();
|
||||||
|
}
|
||||||
|
catch (Exception error)
|
||||||
|
{
|
||||||
|
SafeLog(
|
||||||
|
static (log, message, exception) =>
|
||||||
|
log.Error(message, exception),
|
||||||
|
$"plugin unload failed: {loaded.Manifest.Id}",
|
||||||
|
error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drop both plugin instances and AssemblyLoadContext references. The
|
||||||
|
// CLR completes collectible unload after no plugin-owned object remains
|
||||||
|
// reachable and a normal GC cycle observes the contexts.
|
||||||
|
_loaded.Clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void LoadOne(
|
||||||
|
string id,
|
||||||
|
IReadOnlyDictionary<string, List<PluginDiscoveryResult>> candidates,
|
||||||
|
Dictionary<string, List<Exception>> errors)
|
||||||
|
{
|
||||||
|
if (candidates.TryGetValue(id, out List<PluginDiscoveryResult>? available))
|
||||||
|
{
|
||||||
|
foreach (PluginDiscoveryResult candidate in available)
|
||||||
|
{
|
||||||
|
LoadedPlugin loaded = PluginLoader.Load(
|
||||||
|
candidate.PluginDirectory,
|
||||||
|
candidate.Manifest!,
|
||||||
|
_host);
|
||||||
|
if (!loaded.Success)
|
||||||
|
{
|
||||||
|
AddError(
|
||||||
|
errors,
|
||||||
|
id,
|
||||||
|
loaded.Error ?? new InvalidOperationException(
|
||||||
|
"plugin load failed"));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
loaded.Plugin!.Enable();
|
||||||
|
_loaded.Add(loaded);
|
||||||
|
SafeLog(
|
||||||
|
static (log, message, _) => log.Info(message),
|
||||||
|
$"plugin loaded: {loaded.Manifest.Id} "
|
||||||
|
+ $"({loaded.Manifest.DisplayName})",
|
||||||
|
null);
|
||||||
|
Report(new PluginSessionStatus(
|
||||||
|
loaded.Manifest.Id,
|
||||||
|
PluginSessionStatusKind.Loaded));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
catch (Exception error)
|
||||||
|
{
|
||||||
|
AddError(errors, id, error);
|
||||||
|
ReleaseFailedEnable(loaded);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!errors.TryGetValue(id, out List<Exception>? failures)
|
||||||
|
|| failures.Count == 0)
|
||||||
|
{
|
||||||
|
failures =
|
||||||
|
[
|
||||||
|
new FileNotFoundException(
|
||||||
|
$"plugin '{id}' was not found in the configured plugin roots."),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
string errorText = string.Join(
|
||||||
|
" | ",
|
||||||
|
failures.Select(Describe));
|
||||||
|
Report(new PluginSessionStatus(
|
||||||
|
id,
|
||||||
|
PluginSessionStatusKind.Failed,
|
||||||
|
errorText));
|
||||||
|
SafeLog(
|
||||||
|
static (log, message, _) => log.Warn(message),
|
||||||
|
$"plugin failed: {id}: {errorText}",
|
||||||
|
null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ReleaseFailedEnable(LoadedPlugin loaded)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
loaded.Plugin!.Disable();
|
||||||
|
}
|
||||||
|
catch (Exception error)
|
||||||
|
{
|
||||||
|
SafeLog(
|
||||||
|
static (log, message, exception) =>
|
||||||
|
log.Error(message, exception),
|
||||||
|
$"plugin cleanup after enable failure failed: {loaded.Manifest.Id}",
|
||||||
|
error);
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
loaded.LoadContext!.Unload();
|
||||||
|
}
|
||||||
|
catch (Exception error)
|
||||||
|
{
|
||||||
|
SafeLog(
|
||||||
|
static (log, message, exception) =>
|
||||||
|
log.Error(message, exception),
|
||||||
|
$"plugin unload after enable failure failed: {loaded.Manifest.Id}",
|
||||||
|
error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Report(PluginSessionStatus status)
|
||||||
|
{
|
||||||
|
if (_report is null)
|
||||||
|
return;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_report(status);
|
||||||
|
}
|
||||||
|
catch (Exception error)
|
||||||
|
{
|
||||||
|
SafeLog(
|
||||||
|
static (log, message, exception) =>
|
||||||
|
log.Error(message, exception),
|
||||||
|
$"plugin status observer failed for {status.Plugin}",
|
||||||
|
error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SafeLog(
|
||||||
|
Action<IPluginLogger, string, Exception?> write,
|
||||||
|
string message,
|
||||||
|
Exception? error)
|
||||||
|
{
|
||||||
|
try { write(_host.Log, message, error); }
|
||||||
|
catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string[] DistinctRoots(IEnumerable<string> roots)
|
||||||
|
{
|
||||||
|
StringComparer comparer = OperatingSystem.IsWindows()
|
||||||
|
? StringComparer.OrdinalIgnoreCase
|
||||||
|
: StringComparer.Ordinal;
|
||||||
|
return roots
|
||||||
|
.Where(static root => !string.IsNullOrWhiteSpace(root))
|
||||||
|
.Select(Path.GetFullPath)
|
||||||
|
.Distinct(comparer)
|
||||||
|
.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AddOrdered(List<string> ordered, string id)
|
||||||
|
{
|
||||||
|
if (!ordered.Contains(id, StringComparer.OrdinalIgnoreCase))
|
||||||
|
ordered.Add(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AddError(
|
||||||
|
Dictionary<string, List<Exception>> errors,
|
||||||
|
string id,
|
||||||
|
Exception error)
|
||||||
|
{
|
||||||
|
if (!errors.TryGetValue(id, out List<Exception>? list))
|
||||||
|
{
|
||||||
|
list = [];
|
||||||
|
errors.Add(id, list);
|
||||||
|
}
|
||||||
|
list.Add(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Describe(Exception error)
|
||||||
|
{
|
||||||
|
Exception root = error.GetBaseException();
|
||||||
|
return string.IsNullOrWhiteSpace(root.Message)
|
||||||
|
? root.GetType().Name
|
||||||
|
: root.Message;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsDiscoveryFailure(Exception error) =>
|
||||||
|
error is IOException
|
||||||
|
or UnauthorizedAccessException
|
||||||
|
or ArgumentException
|
||||||
|
or NotSupportedException
|
||||||
|
or System.Security.SecurityException;
|
||||||
|
}
|
||||||
|
|
@ -56,6 +56,11 @@ internal sealed class HeadlessProcessHost : IDisposable
|
||||||
paths.ConfigDirectory);
|
paths.ConfigDirectory);
|
||||||
var sessions = new List<HeadlessSessionHost>(
|
var sessions = new List<HeadlessSessionHost>(
|
||||||
configuration.Sessions.Count);
|
configuration.Sessions.Count);
|
||||||
|
string[] pluginRoots =
|
||||||
|
[
|
||||||
|
Path.Combine(AppContext.BaseDirectory, "plugins"),
|
||||||
|
paths.PluginsDirectory,
|
||||||
|
];
|
||||||
HeadlessProcessContentOwner? content = null;
|
HeadlessProcessContentOwner? content = null;
|
||||||
HeadlessProcessResourceSampler? resources = null;
|
HeadlessProcessResourceSampler? resources = null;
|
||||||
// FA6: constructed unconditionally — cheap, and every non-gate
|
// FA6: constructed unconditionally — cheap, and every non-gate
|
||||||
|
|
@ -104,7 +109,8 @@ internal sealed class HeadlessProcessHost : IDisposable
|
||||||
sessionOperations,
|
sessionOperations,
|
||||||
timeProvider,
|
timeProvider,
|
||||||
contentLease: contentLease,
|
contentLease: contentLease,
|
||||||
gateCoordinator: gateCoordinator));
|
gateCoordinator: gateCoordinator,
|
||||||
|
pluginRoots: pluginRoots));
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
using AcDream.Headless.Configuration;
|
using AcDream.Headless.Configuration;
|
||||||
using AcDream.Headless.Credentials;
|
using AcDream.Headless.Credentials;
|
||||||
using AcDream.Headless.Diagnostics;
|
using AcDream.Headless.Diagnostics;
|
||||||
|
using AcDream.Headless.Plugins;
|
||||||
using AcDream.Headless.Policies;
|
using AcDream.Headless.Policies;
|
||||||
using AcDream.Core.Net.Messages;
|
using AcDream.Core.Net.Messages;
|
||||||
using AcDream.Core.Physics;
|
using AcDream.Core.Physics;
|
||||||
|
|
@ -155,6 +156,7 @@ internal sealed class HeadlessSessionHost : IDisposable
|
||||||
private readonly IDisposable _hostLease;
|
private readonly IDisposable _hostLease;
|
||||||
private readonly IHeadlessBotPolicy _policy;
|
private readonly IHeadlessBotPolicy _policy;
|
||||||
private readonly IDisposable _policySubscription;
|
private readonly IDisposable _policySubscription;
|
||||||
|
private readonly HeadlessPluginSession _pluginSession;
|
||||||
private readonly LiveSessionHost _liveSession;
|
private readonly LiveSessionHost _liveSession;
|
||||||
private readonly RuntimeLocalPlayerFrameController _localPlayerFrame;
|
private readonly RuntimeLocalPlayerFrameController _localPlayerFrame;
|
||||||
private readonly HeadlessProcessContentOwner.HeadlessProcessContentLease?
|
private readonly HeadlessProcessContentOwner.HeadlessProcessContentLease?
|
||||||
|
|
@ -238,7 +240,8 @@ internal sealed class HeadlessSessionHost : IDisposable
|
||||||
contentLease = null,
|
contentLease = null,
|
||||||
IHeadlessBotPolicy? policyOverride = null,
|
IHeadlessBotPolicy? policyOverride = null,
|
||||||
IRuntimePlacementProjectionSink? placementSinkOverride = null,
|
IRuntimePlacementProjectionSink? placementSinkOverride = null,
|
||||||
FellowshipAllegianceGateCoordinator? gateCoordinator = null)
|
FellowshipAllegianceGateCoordinator? gateCoordinator = null,
|
||||||
|
IEnumerable<string>? pluginRoots = null)
|
||||||
{
|
{
|
||||||
_descriptor = descriptor
|
_descriptor = descriptor
|
||||||
?? throw new ArgumentNullException(nameof(descriptor));
|
?? throw new ArgumentNullException(nameof(descriptor));
|
||||||
|
|
@ -263,6 +266,7 @@ internal sealed class HeadlessSessionHost : IDisposable
|
||||||
IDisposable? hostLease = null;
|
IDisposable? hostLease = null;
|
||||||
IHeadlessBotPolicy? policy = null;
|
IHeadlessBotPolicy? policy = null;
|
||||||
IDisposable? policySubscription = null;
|
IDisposable? policySubscription = null;
|
||||||
|
HeadlessPluginSession? pluginSession = null;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var gameplay = new HeadlessGameplayOperations();
|
var gameplay = new HeadlessGameplayOperations();
|
||||||
|
|
@ -297,6 +301,13 @@ internal sealed class HeadlessSessionHost : IDisposable
|
||||||
// descriptor.StatusFile is unset — every call site below stays
|
// descriptor.StatusFile is unset — every call site below stays
|
||||||
// unconditional.
|
// unconditional.
|
||||||
var statusWriter = new SessionStatusWriter(descriptor.StatusFile);
|
var statusWriter = new SessionStatusWriter(descriptor.StatusFile);
|
||||||
|
pluginSession = HeadlessPluginSession.Start(
|
||||||
|
runtime,
|
||||||
|
diagnostics,
|
||||||
|
statusWriter,
|
||||||
|
descriptor.Id,
|
||||||
|
pluginRoots ?? [],
|
||||||
|
descriptor.Plugins);
|
||||||
var liveSession = new LiveSessionHost(
|
var liveSession = new LiveSessionHost(
|
||||||
runtime.Session,
|
runtime.Session,
|
||||||
new LiveSessionHostBindings(
|
new LiveSessionHostBindings(
|
||||||
|
|
@ -402,9 +413,11 @@ internal sealed class HeadlessSessionHost : IDisposable
|
||||||
_hostLease = hostLease;
|
_hostLease = hostLease;
|
||||||
_policy = policy;
|
_policy = policy;
|
||||||
_policySubscription = policySubscription;
|
_policySubscription = policySubscription;
|
||||||
|
_pluginSession = pluginSession;
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
|
pluginSession?.Dispose();
|
||||||
policySubscription?.Dispose();
|
policySubscription?.Dispose();
|
||||||
policy?.Dispose();
|
policy?.Dispose();
|
||||||
hostLease?.Dispose();
|
hostLease?.Dispose();
|
||||||
|
|
@ -427,6 +440,7 @@ internal sealed class HeadlessSessionHost : IDisposable
|
||||||
/// production code uses to reach the same state.
|
/// production code uses to reach the same state.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal HeadlessCharacterOptionsSeeder? OptionsSeeder => _optionsSeeder;
|
internal HeadlessCharacterOptionsSeeder? OptionsSeeder => _optionsSeeder;
|
||||||
|
internal HeadlessPluginSession Plugins => _pluginSession;
|
||||||
internal string SessionId => _descriptor.Id;
|
internal string SessionId => _descriptor.Id;
|
||||||
internal string ActiveCharacterName { get; private set; } =
|
internal string ActiveCharacterName { get; private set; } =
|
||||||
string.Empty;
|
string.Empty;
|
||||||
|
|
@ -638,22 +652,26 @@ internal sealed class HeadlessSessionHost : IDisposable
|
||||||
_disposeStage++;
|
_disposeStage++;
|
||||||
break;
|
break;
|
||||||
case 4:
|
case 4:
|
||||||
_hostLease.Dispose();
|
_pluginSession.Dispose();
|
||||||
_disposeStage++;
|
_disposeStage++;
|
||||||
break;
|
break;
|
||||||
case 5:
|
case 5:
|
||||||
_credential.Dispose();
|
_hostLease.Dispose();
|
||||||
_disposeStage++;
|
_disposeStage++;
|
||||||
break;
|
break;
|
||||||
case 6:
|
case 6:
|
||||||
Runtime.Dispose();
|
_credential.Dispose();
|
||||||
_disposeStage++;
|
_disposeStage++;
|
||||||
break;
|
break;
|
||||||
case 7:
|
case 7:
|
||||||
_contentLease?.Dispose();
|
Runtime.Dispose();
|
||||||
_disposeStage++;
|
_disposeStage++;
|
||||||
break;
|
break;
|
||||||
case 8:
|
case 8:
|
||||||
|
_contentLease?.Dispose();
|
||||||
|
_disposeStage++;
|
||||||
|
break;
|
||||||
|
case 9:
|
||||||
_diagnostics.Message(
|
_diagnostics.Message(
|
||||||
_descriptor.Id,
|
_descriptor.Id,
|
||||||
"disposed",
|
"disposed",
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,9 @@ internal sealed record HeadlessPathSet(
|
||||||
string DataDirectory,
|
string DataDirectory,
|
||||||
string CacheDirectory)
|
string CacheDirectory)
|
||||||
{
|
{
|
||||||
|
internal string PluginsDirectory =>
|
||||||
|
Path.Combine(DataDirectory, "plugins");
|
||||||
|
|
||||||
internal static HeadlessPathSet Resolve(
|
internal static HeadlessPathSet Resolve(
|
||||||
HeadlessPathOverrides overrides,
|
HeadlessPathOverrides overrides,
|
||||||
IHeadlessPlatformEnvironment? platform = null)
|
IHeadlessPlatformEnvironment? platform = null)
|
||||||
|
|
|
||||||
150
src/AcDream.Headless/Plugins/HeadlessPluginHost.cs
Normal file
150
src/AcDream.Headless/Plugins/HeadlessPluginHost.cs
Normal file
|
|
@ -0,0 +1,150 @@
|
||||||
|
using AcDream.Plugin.Abstractions;
|
||||||
|
using AcDream.Runtime;
|
||||||
|
|
||||||
|
namespace AcDream.Headless.Plugins;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// No-window plugin surface over one exact <see cref="GameRuntime"/>. State is
|
||||||
|
/// projected on demand from Runtime's canonical entity view, events come from
|
||||||
|
/// Runtime's ordered event source, and selection is the exact J5 action owner;
|
||||||
|
/// this adapter owns no gameplay mirror.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class HeadlessPluginHost
|
||||||
|
: IPluginHost,
|
||||||
|
IGameState,
|
||||||
|
IEvents,
|
||||||
|
IRuntimeEventObserver,
|
||||||
|
IDisposable
|
||||||
|
{
|
||||||
|
private readonly GameRuntime _runtime;
|
||||||
|
private readonly IDisposable _eventSubscription;
|
||||||
|
private readonly object _eventGate = new();
|
||||||
|
private Action<WorldEntitySnapshot>? _entitySpawned;
|
||||||
|
private bool _disposed;
|
||||||
|
|
||||||
|
internal HeadlessPluginHost(
|
||||||
|
GameRuntime runtime,
|
||||||
|
IPluginLogger logger)
|
||||||
|
{
|
||||||
|
_runtime = runtime ?? throw new ArgumentNullException(nameof(runtime));
|
||||||
|
Log = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||||
|
_eventSubscription = runtime.Subscribe(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool HasUi => false;
|
||||||
|
public IPluginLogger Log { get; }
|
||||||
|
public IGameState State => this;
|
||||||
|
public IEvents Events => this;
|
||||||
|
public ISelectionService Selection => _runtime.ActionOwner.Selection;
|
||||||
|
public IUiRegistry Ui => NoOpUiRegistry.Instance;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Immutable point-in-time values produced directly from Runtime on each
|
||||||
|
/// read. The caller owns the returned snapshot list; this host retains no
|
||||||
|
/// entity collection and therefore cannot become a second gameplay owner.
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyList<WorldEntitySnapshot> Entities
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||||
|
var visitor = new SnapshotVisitor(_runtime);
|
||||||
|
_runtime.Entities.Visit(visitor);
|
||||||
|
return visitor.Snapshots;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public event Action<WorldEntitySnapshot> EntitySpawned
|
||||||
|
{
|
||||||
|
add
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(value);
|
||||||
|
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||||
|
lock (_eventGate)
|
||||||
|
_entitySpawned += value;
|
||||||
|
|
||||||
|
// Match the graphical WorldEvents contract: a late subscriber
|
||||||
|
// immediately observes the canonical world that exists now.
|
||||||
|
foreach (WorldEntitySnapshot snapshot in Entities)
|
||||||
|
Invoke(value, snapshot);
|
||||||
|
}
|
||||||
|
remove
|
||||||
|
{
|
||||||
|
if (value is null)
|
||||||
|
return;
|
||||||
|
lock (_eventGate)
|
||||||
|
_entitySpawned -= value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (_disposed)
|
||||||
|
return;
|
||||||
|
_eventSubscription.Dispose();
|
||||||
|
_disposed = true;
|
||||||
|
lock (_eventGate)
|
||||||
|
_entitySpawned = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void OnEntity(in RuntimeEntityDelta delta)
|
||||||
|
{
|
||||||
|
if (_disposed || delta.Change != RuntimeEntityChange.Registered)
|
||||||
|
return;
|
||||||
|
Action<WorldEntitySnapshot>? handlers;
|
||||||
|
lock (_eventGate)
|
||||||
|
handlers = _entitySpawned;
|
||||||
|
if (handlers is null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
WorldEntitySnapshot snapshot = Convert(_runtime, delta.Entity);
|
||||||
|
foreach (Action<WorldEntitySnapshot> handler
|
||||||
|
in handlers.GetInvocationList().Cast<Action<WorldEntitySnapshot>>())
|
||||||
|
{
|
||||||
|
Invoke(handler, snapshot);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void OnLifecycle(in RuntimeLifecycleDelta delta) { }
|
||||||
|
public void OnCommand(in RuntimeCommandDelta delta) { }
|
||||||
|
public void OnInventory(in RuntimeInventoryDelta delta) { }
|
||||||
|
public void OnChat(in RuntimeChatDelta delta) { }
|
||||||
|
public void OnMovement(in RuntimeMovementDelta delta) { }
|
||||||
|
public void OnPortal(in RuntimePortalDelta delta) { }
|
||||||
|
public void OnCombat(in RuntimeCombatDelta delta) { }
|
||||||
|
|
||||||
|
private static WorldEntitySnapshot Convert(
|
||||||
|
GameRuntime runtime,
|
||||||
|
in RuntimeEntitySnapshot entity)
|
||||||
|
{
|
||||||
|
uint sourceId = runtime.EntityObjects.Entities.TryGetActive(
|
||||||
|
entity.Identity.ServerGuid,
|
||||||
|
out AcDream.Runtime.Entities.RuntimeEntityRecord record)
|
||||||
|
? record.Snapshot.SetupTableId ?? 0u
|
||||||
|
: 0u;
|
||||||
|
return new WorldEntitySnapshot(
|
||||||
|
entity.Identity.LocalEntityId,
|
||||||
|
sourceId,
|
||||||
|
entity.Position?.Frame.Origin ?? default,
|
||||||
|
entity.Position?.Frame.Orientation
|
||||||
|
?? System.Numerics.Quaternion.Identity);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Invoke(
|
||||||
|
Action<WorldEntitySnapshot> handler,
|
||||||
|
WorldEntitySnapshot snapshot)
|
||||||
|
{
|
||||||
|
try { handler(snapshot); }
|
||||||
|
catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class SnapshotVisitor(GameRuntime runtime)
|
||||||
|
: IRuntimeEntityVisitor
|
||||||
|
{
|
||||||
|
internal List<WorldEntitySnapshot> Snapshots { get; } =
|
||||||
|
new(runtime.Entities.Count);
|
||||||
|
|
||||||
|
public void Visit(in RuntimeEntitySnapshot entity) =>
|
||||||
|
Snapshots.Add(Convert(runtime, entity));
|
||||||
|
}
|
||||||
|
}
|
||||||
43
src/AcDream.Headless/Plugins/HeadlessPluginLogger.cs
Normal file
43
src/AcDream.Headless/Plugins/HeadlessPluginLogger.cs
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
using AcDream.Headless.Diagnostics;
|
||||||
|
using AcDream.Plugin.Abstractions;
|
||||||
|
|
||||||
|
namespace AcDream.Headless.Plugins;
|
||||||
|
|
||||||
|
internal sealed class HeadlessPluginLogger : IPluginLogger
|
||||||
|
{
|
||||||
|
private readonly HeadlessDiagnosticWriter _diagnostics;
|
||||||
|
private readonly string _sessionId;
|
||||||
|
private readonly Func<ulong> _generation;
|
||||||
|
|
||||||
|
internal HeadlessPluginLogger(
|
||||||
|
HeadlessDiagnosticWriter diagnostics,
|
||||||
|
string sessionId,
|
||||||
|
Func<ulong> generation)
|
||||||
|
{
|
||||||
|
_diagnostics = diagnostics
|
||||||
|
?? throw new ArgumentNullException(nameof(diagnostics));
|
||||||
|
_sessionId = sessionId
|
||||||
|
?? throw new ArgumentNullException(nameof(sessionId));
|
||||||
|
_generation = generation
|
||||||
|
?? throw new ArgumentNullException(nameof(generation));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Info(string message) => Write("info", message);
|
||||||
|
public void Warn(string message) => Write("warn", message);
|
||||||
|
|
||||||
|
public void Error(string message, Exception? exception = null)
|
||||||
|
{
|
||||||
|
if (exception is not null)
|
||||||
|
{
|
||||||
|
_diagnostics.Failure(_sessionId, "plugin", exception);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Write("error", message);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Write(string level, string message) =>
|
||||||
|
_diagnostics.Message(
|
||||||
|
_sessionId,
|
||||||
|
$"plugin-{level}:{message}",
|
||||||
|
_generation());
|
||||||
|
}
|
||||||
109
src/AcDream.Headless/Plugins/HeadlessPluginSession.cs
Normal file
109
src/AcDream.Headless/Plugins/HeadlessPluginSession.cs
Normal file
|
|
@ -0,0 +1,109 @@
|
||||||
|
using AcDream.Core.Plugins;
|
||||||
|
using AcDream.Headless.Diagnostics;
|
||||||
|
using AcDream.Plugin.Abstractions;
|
||||||
|
using AcDream.Runtime;
|
||||||
|
using AcDream.Runtime.Session;
|
||||||
|
|
||||||
|
namespace AcDream.Headless.Plugins;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Headless composition wrapper that keeps plugin disable/unsubscribe/unload
|
||||||
|
/// ahead of canonical Runtime disposal.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class HeadlessPluginSession : IDisposable
|
||||||
|
{
|
||||||
|
private readonly HeadlessPluginHost _host;
|
||||||
|
private readonly PluginSession _plugins;
|
||||||
|
private int _disposeStage;
|
||||||
|
private bool _disposed;
|
||||||
|
|
||||||
|
private HeadlessPluginSession(
|
||||||
|
HeadlessPluginHost host,
|
||||||
|
PluginSession plugins)
|
||||||
|
{
|
||||||
|
_host = host;
|
||||||
|
_plugins = plugins;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal int LoadedCount => _plugins.LoadedCount;
|
||||||
|
internal IPluginHost Host => _host;
|
||||||
|
|
||||||
|
internal IReadOnlyList<WeakReference> CaptureLoadContextWeakReferences() =>
|
||||||
|
_plugins.CaptureLoadContextWeakReferences();
|
||||||
|
|
||||||
|
internal static HeadlessPluginSession Start(
|
||||||
|
GameRuntime runtime,
|
||||||
|
HeadlessDiagnosticWriter diagnostics,
|
||||||
|
SessionStatusWriter statusWriter,
|
||||||
|
string sessionId,
|
||||||
|
IEnumerable<string> roots,
|
||||||
|
IReadOnlyList<string>? allowList)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(runtime);
|
||||||
|
ArgumentNullException.ThrowIfNull(diagnostics);
|
||||||
|
ArgumentNullException.ThrowIfNull(statusWriter);
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(sessionId);
|
||||||
|
ArgumentNullException.ThrowIfNull(roots);
|
||||||
|
|
||||||
|
var host = new HeadlessPluginHost(
|
||||||
|
runtime,
|
||||||
|
new HeadlessPluginLogger(
|
||||||
|
diagnostics,
|
||||||
|
sessionId,
|
||||||
|
() => runtime.Generation.Value));
|
||||||
|
var plugins = new PluginSession(
|
||||||
|
host,
|
||||||
|
status => Report(statusWriter, sessionId, status));
|
||||||
|
try
|
||||||
|
{
|
||||||
|
plugins.Start(roots, allowList);
|
||||||
|
return new HeadlessPluginSession(host, plugins);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
plugins.Dispose();
|
||||||
|
host.Dispose();
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (_disposed)
|
||||||
|
return;
|
||||||
|
while (!_disposed)
|
||||||
|
{
|
||||||
|
switch (_disposeStage)
|
||||||
|
{
|
||||||
|
case 0:
|
||||||
|
_plugins.Dispose();
|
||||||
|
_disposeStage++;
|
||||||
|
break;
|
||||||
|
case 1:
|
||||||
|
_host.Dispose();
|
||||||
|
_disposeStage++;
|
||||||
|
_disposed = true;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"Unknown headless plugin teardown stage.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Report(
|
||||||
|
SessionStatusWriter writer,
|
||||||
|
string sessionId,
|
||||||
|
PluginSessionStatus status)
|
||||||
|
{
|
||||||
|
if (status.Kind == PluginSessionStatusKind.Loaded)
|
||||||
|
{
|
||||||
|
writer.PluginLoaded(sessionId, status.Plugin);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
writer.PluginFailed(
|
||||||
|
sessionId,
|
||||||
|
status.Plugin,
|
||||||
|
status.Error ?? "plugin failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -7,6 +7,14 @@ namespace AcDream.Plugin.Abstractions;
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public interface IPluginHost
|
public interface IPluginHost
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// <see langword="true"/> when <see cref="Ui"/> registrations can be
|
||||||
|
/// projected by this host. No-window hosts return <see langword="false"/>
|
||||||
|
/// and expose <see cref="NoOpUiRegistry.Instance"/> so a plugin may keep
|
||||||
|
/// one code path while deliberately omitting presentation work.
|
||||||
|
/// </summary>
|
||||||
|
bool HasUi { get; }
|
||||||
|
|
||||||
IPluginLogger Log { get; }
|
IPluginLogger Log { get; }
|
||||||
IGameState State { get; }
|
IGameState State { get; }
|
||||||
IEvents Events { get; }
|
IEvents Events { get; }
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,10 @@ namespace AcDream.Plugin.Abstractions;
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Plugin-facing UI registration. A plugin ships a markup file (KSML-style) +
|
/// Plugin-facing UI registration. A plugin ships a markup file (KSML-style) +
|
||||||
/// a binding object exposing the data properties the markup binds to, and
|
/// a binding object exposing the data properties the markup binds to, and
|
||||||
/// registers it from <c>Enable()</c>. Calls made before the GL window opens are
|
/// registers it from <c>Enable()</c>. Graphical hosts buffer registrations until
|
||||||
/// buffered and drained once the UI host exists.
|
/// their retained UI exists. A host whose <see cref="IPluginHost.HasUi"/> is
|
||||||
|
/// <see langword="false"/> exposes <see cref="NoOpUiRegistry.Instance"/> and
|
||||||
|
/// intentionally discards registrations.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public interface IUiRegistry
|
public interface IUiRegistry
|
||||||
{
|
{
|
||||||
|
|
@ -12,3 +14,21 @@ public interface IUiRegistry
|
||||||
/// <param name="binding">Object whose properties the markup's {Bindings} resolve against.</param>
|
/// <param name="binding">Object whose properties the markup's {Bindings} resolve against.</param>
|
||||||
void AddMarkupPanel(string markupPath, object binding);
|
void AddMarkupPanel(string markupPath, object binding);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// BCL-only UI sink for no-window plugin hosts. It intentionally retains
|
||||||
|
/// neither markup paths nor binding objects, so a UI registration cannot keep
|
||||||
|
/// a plugin assembly alive after its collectible load context is unloaded.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class NoOpUiRegistry : IUiRegistry
|
||||||
|
{
|
||||||
|
public static NoOpUiRegistry Instance { get; } = new();
|
||||||
|
|
||||||
|
private NoOpUiRegistry()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public void AddMarkupPanel(string markupPath, object binding)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,9 +13,9 @@ namespace AcDream.Runtime.Session;
|
||||||
/// is a single shared-stdout JSONL diagnostics stream with no per-session
|
/// is a single shared-stdout JSONL diagnostics stream with no per-session
|
||||||
/// file; this class writes one file per session, meant to be read by an
|
/// file; this class writes one file per session, meant to be read by an
|
||||||
/// external process (the launcher) rather than scraped from console output.
|
/// external process (the launcher) rather than scraped from console output.
|
||||||
/// Event shapes are versioned (<c>"v":1</c>) so a future event kind
|
/// Event shapes are versioned (<c>"v":1</c>); LA5's
|
||||||
/// (<c>pluginLoaded</c>/<c>pluginFailed</c>, LA5) can be added without
|
/// <c>pluginLoaded</c>/<c>pluginFailed</c> additions use that same envelope
|
||||||
/// breaking an existing reader.
|
/// without breaking an existing reader.
|
||||||
/// </para>
|
/// </para>
|
||||||
///
|
///
|
||||||
/// <para>
|
/// <para>
|
||||||
|
|
@ -29,9 +29,11 @@ namespace AcDream.Runtime.Session;
|
||||||
/// </para>
|
/// </para>
|
||||||
///
|
///
|
||||||
/// <para>
|
/// <para>
|
||||||
/// <strong>Never write credential material into this stream.</strong> Every
|
/// <strong>Never write credential material into this stream.</strong> LA5's
|
||||||
/// event method below takes only identifiers, names, and counts — there is no
|
/// <see cref="PluginFailed"/> diagnostic is caller-supplied text, so hosts may
|
||||||
/// parameter shape that could carry a password, by construction.
|
/// pass only the plugin lifecycle failure and must never append session
|
||||||
|
/// credentials or other secrets. Neither plugin host exposes credentials
|
||||||
|
/// through <c>IPluginHost</c>.
|
||||||
/// </para>
|
/// </para>
|
||||||
///
|
///
|
||||||
/// <para>
|
/// <para>
|
||||||
|
|
@ -205,6 +207,27 @@ public sealed class SessionStatusWriter
|
||||||
characterName,
|
characterName,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
public void PluginLoaded(string sessionId, string plugin) =>
|
||||||
|
Write(new
|
||||||
|
{
|
||||||
|
v = VocabularyVersion,
|
||||||
|
e = "pluginLoaded",
|
||||||
|
t = Now(),
|
||||||
|
sessionId,
|
||||||
|
plugin,
|
||||||
|
});
|
||||||
|
|
||||||
|
public void PluginFailed(string sessionId, string plugin, string error) =>
|
||||||
|
Write(new
|
||||||
|
{
|
||||||
|
v = VocabularyVersion,
|
||||||
|
e = "pluginFailed",
|
||||||
|
t = Now(),
|
||||||
|
sessionId,
|
||||||
|
plugin,
|
||||||
|
error,
|
||||||
|
});
|
||||||
|
|
||||||
public void Disconnected(string sessionId, string reason)
|
public void Disconnected(string sessionId, string reason)
|
||||||
{
|
{
|
||||||
if (!IsEnabled)
|
if (!IsEnabled)
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,15 @@
|
||||||
<ProjectReference Include="..\..\src\AcDream.Launcher.Core\AcDream.Launcher.Core.csproj" />
|
<ProjectReference Include="..\..\src\AcDream.Launcher.Core\AcDream.Launcher.Core.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<!-- Build ordering only. Tests copy this DLL into a temporary plugin root
|
||||||
|
and the production loader loads it through a collectible ALC. -->
|
||||||
|
<ProjectReference Include="..\AcDream.Plugin.Tests.Fixtures.HostPlugin\AcDream.Plugin.Tests.Fixtures.HostPlugin.csproj">
|
||||||
|
<ReferenceOutputAssembly>false</ReferenceOutputAssembly>
|
||||||
|
<SkipGetTargetFrameworkProperties>true</SkipGetTargetFrameworkProperties>
|
||||||
|
</ProjectReference>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<Compile Include="..\Fixtures\campaign-la\LauncherCoreSessionConfigFixture.cs"
|
<Compile Include="..\Fixtures\campaign-la\LauncherCoreSessionConfigFixture.cs"
|
||||||
Link="Fixtures\LauncherCoreSessionConfigFixture.cs" />
|
Link="Fixtures\LauncherCoreSessionConfigFixture.cs" />
|
||||||
|
|
|
||||||
202
tests/AcDream.App.Tests/Plugins/GraphicalPluginSessionTests.cs
Normal file
202
tests/AcDream.App.Tests/Plugins/GraphicalPluginSessionTests.cs
Normal file
|
|
@ -0,0 +1,202 @@
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
using AcDream.App.Plugins;
|
||||||
|
using AcDream.Core.Plugins;
|
||||||
|
using AcDream.Core.Selection;
|
||||||
|
using AcDream.Platform;
|
||||||
|
using AcDream.Plugin.Abstractions;
|
||||||
|
using AcDream.Runtime.Session;
|
||||||
|
|
||||||
|
namespace AcDream.App.Tests.Plugins;
|
||||||
|
|
||||||
|
public sealed class GraphicalPluginSessionTests
|
||||||
|
{
|
||||||
|
private const string FixtureId = "acdream.test.host-fixture";
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ConfiguredSetLoadsOnlyAllowedPluginAndReportsBothOutcomes()
|
||||||
|
{
|
||||||
|
using var temporary = new TemporaryDirectory();
|
||||||
|
ApplicationPathSet paths = Paths(temporary.Path);
|
||||||
|
InstallFixture(paths.PluginsDirectory, FixtureId);
|
||||||
|
string statusPath = Path.Combine(temporary.Path, "status.jsonl");
|
||||||
|
var logger = new CapturingLogger();
|
||||||
|
var state = new WorldGameState();
|
||||||
|
var events = new WorldEvents();
|
||||||
|
var selection = new SelectionState();
|
||||||
|
var ui = new BufferedUiRegistry();
|
||||||
|
var host = new AppPluginHost(logger, state, events, selection, ui);
|
||||||
|
|
||||||
|
using GraphicalPluginSession plugins = GraphicalPluginSession.Start(
|
||||||
|
paths,
|
||||||
|
[FixtureId.ToUpperInvariant(), "acdream.test.missing"],
|
||||||
|
"gui-session",
|
||||||
|
host,
|
||||||
|
new SessionStatusWriter(statusPath));
|
||||||
|
|
||||||
|
Assert.Equal(1, plugins.LoadedCount);
|
||||||
|
Assert.True(host.HasUi);
|
||||||
|
AssertPanelWasRegisteredAndReleaseBinding(ui);
|
||||||
|
Assert.Contains(
|
||||||
|
logger.Messages,
|
||||||
|
message => message.Contains("fixture-enabled:hasUi=True", StringComparison.Ordinal));
|
||||||
|
|
||||||
|
JsonElement[] statuses = ReadStatuses(statusPath);
|
||||||
|
Assert.Equal(["pluginLoaded", "pluginFailed"], EventNames(statuses));
|
||||||
|
Assert.Equal(FixtureId, statuses[0].GetProperty("plugin").GetString());
|
||||||
|
Assert.Equal(
|
||||||
|
"acdream.test.missing",
|
||||||
|
statuses[1].GetProperty("plugin").GetString());
|
||||||
|
Assert.Contains(
|
||||||
|
"not found",
|
||||||
|
statuses[1].GetProperty("error").GetString(),
|
||||||
|
StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
WeakReference context = Assert.Single(
|
||||||
|
plugins.CaptureLoadContextWeakReferences());
|
||||||
|
plugins.Dispose();
|
||||||
|
Collect(context);
|
||||||
|
Assert.False(context.IsAlive);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ExplicitEmptyConfiguredSetLoadsNone()
|
||||||
|
{
|
||||||
|
using var temporary = new TemporaryDirectory();
|
||||||
|
ApplicationPathSet paths = Paths(temporary.Path);
|
||||||
|
InstallFixture(paths.PluginsDirectory, FixtureId);
|
||||||
|
string statusPath = Path.Combine(temporary.Path, "status.jsonl");
|
||||||
|
var ui = new BufferedUiRegistry();
|
||||||
|
var host = new AppPluginHost(
|
||||||
|
new CapturingLogger(),
|
||||||
|
new WorldGameState(),
|
||||||
|
new WorldEvents(),
|
||||||
|
new SelectionState(),
|
||||||
|
ui);
|
||||||
|
|
||||||
|
using GraphicalPluginSession plugins = GraphicalPluginSession.Start(
|
||||||
|
paths,
|
||||||
|
[],
|
||||||
|
"gui-session",
|
||||||
|
host,
|
||||||
|
new SessionStatusWriter(statusPath));
|
||||||
|
|
||||||
|
Assert.Equal(0, plugins.LoadedCount);
|
||||||
|
Assert.Empty(ui.Drain());
|
||||||
|
Assert.False(File.Exists(statusPath));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ApplicationPathSet Paths(string root) => new(
|
||||||
|
Path.Combine(root, "config"),
|
||||||
|
Path.Combine(root, "data"),
|
||||||
|
Path.Combine(root, "cache"),
|
||||||
|
LegacyConfigDirectory: null);
|
||||||
|
|
||||||
|
[MethodImpl(MethodImplOptions.NoInlining)]
|
||||||
|
private static void AssertPanelWasRegisteredAndReleaseBinding(
|
||||||
|
BufferedUiRegistry ui)
|
||||||
|
{
|
||||||
|
BufferedUiRegistry.Pending panel = Assert.Single(ui.Drain());
|
||||||
|
Assert.EndsWith(
|
||||||
|
"fixture-panel.xml",
|
||||||
|
panel.MarkupPath,
|
||||||
|
StringComparison.Ordinal);
|
||||||
|
Assert.Equal(
|
||||||
|
"AcDream.Plugin.Tests.Fixtures.HostPlugin",
|
||||||
|
panel.Binding.GetType().Assembly.GetName().Name);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static JsonElement[] ReadStatuses(string path) =>
|
||||||
|
File.ReadAllLines(path)
|
||||||
|
.Select(static line => JsonDocument.Parse(line).RootElement.Clone())
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
private static string[] EventNames(IEnumerable<JsonElement> events) =>
|
||||||
|
events.Select(static item => item.GetProperty("e").GetString()!).ToArray();
|
||||||
|
|
||||||
|
private static void InstallFixture(string root, string id)
|
||||||
|
{
|
||||||
|
string source = FixtureAssemblyPath();
|
||||||
|
Assert.True(File.Exists(source), $"fixture DLL not found: {source}");
|
||||||
|
string pluginDirectory = Path.Combine(root, "host-fixture");
|
||||||
|
Directory.CreateDirectory(pluginDirectory);
|
||||||
|
string fileName = Path.GetFileName(source);
|
||||||
|
File.Copy(source, Path.Combine(pluginDirectory, fileName));
|
||||||
|
File.WriteAllText(
|
||||||
|
Path.Combine(pluginDirectory, "plugin.json"),
|
||||||
|
JsonSerializer.Serialize(new
|
||||||
|
{
|
||||||
|
id,
|
||||||
|
displayName = "Host fixture",
|
||||||
|
version = "1.0.0",
|
||||||
|
entryDll = fileName,
|
||||||
|
apiVersion = 1,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string FixtureAssemblyPath()
|
||||||
|
{
|
||||||
|
string configuration = new DirectoryInfo(AppContext.BaseDirectory)
|
||||||
|
.Parent!.Name;
|
||||||
|
string root = FindRepoRoot(AppContext.BaseDirectory);
|
||||||
|
return Path.Combine(
|
||||||
|
root,
|
||||||
|
"tests",
|
||||||
|
"AcDream.Plugin.Tests.Fixtures.HostPlugin",
|
||||||
|
"bin",
|
||||||
|
configuration,
|
||||||
|
"net10.0",
|
||||||
|
"AcDream.Plugin.Tests.Fixtures.HostPlugin.dll");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string FindRepoRoot(string start)
|
||||||
|
{
|
||||||
|
DirectoryInfo? directory = new(start);
|
||||||
|
while (directory is not null
|
||||||
|
&& !File.Exists(Path.Combine(directory.FullName, "AcDream.slnx")))
|
||||||
|
{
|
||||||
|
directory = directory.Parent;
|
||||||
|
}
|
||||||
|
return directory?.FullName
|
||||||
|
?? throw new InvalidOperationException("Repository root not found.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Collect(WeakReference reference)
|
||||||
|
{
|
||||||
|
for (int attempt = 0; attempt < 10 && reference.IsAlive; attempt++)
|
||||||
|
{
|
||||||
|
GC.Collect();
|
||||||
|
GC.WaitForPendingFinalizers();
|
||||||
|
GC.Collect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class CapturingLogger : IPluginLogger
|
||||||
|
{
|
||||||
|
internal List<string> Messages { get; } = [];
|
||||||
|
|
||||||
|
public void Info(string message) => Messages.Add(message);
|
||||||
|
public void Warn(string message) => Messages.Add(message);
|
||||||
|
public void Error(string message, Exception? exception = null) =>
|
||||||
|
Messages.Add(exception is null ? message : $"{message}: {exception.Message}");
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class TemporaryDirectory : IDisposable
|
||||||
|
{
|
||||||
|
internal TemporaryDirectory()
|
||||||
|
{
|
||||||
|
Path = System.IO.Path.Combine(
|
||||||
|
System.IO.Path.GetTempPath(),
|
||||||
|
$"acdream-graphical-plugins-{Guid.NewGuid():N}");
|
||||||
|
Directory.CreateDirectory(Path);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal string Path { get; }
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (Directory.Exists(Path))
|
||||||
|
Directory.Delete(Path, recursive: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -28,6 +28,7 @@ public class PluginLoaderTests
|
||||||
|
|
||||||
private sealed class StubHost : IPluginHost
|
private sealed class StubHost : IPluginHost
|
||||||
{
|
{
|
||||||
|
public bool HasUi => true;
|
||||||
public IPluginLogger Log { get; } = new StubLogger();
|
public IPluginLogger Log { get; } = new StubLogger();
|
||||||
public IGameState State { get; } = new StubState();
|
public IGameState State { get; } = new StubState();
|
||||||
public IEvents Events { get; } = new StubEvents();
|
public IEvents Events { get; } = new StubEvents();
|
||||||
|
|
@ -84,6 +85,8 @@ public class PluginLoaderTests
|
||||||
Assert.True(loaded.Success);
|
Assert.True(loaded.Success);
|
||||||
Assert.NotNull(loaded.Plugin);
|
Assert.NotNull(loaded.Plugin);
|
||||||
Assert.Equal("HelloPlugin", loaded.Plugin!.GetType().Name);
|
Assert.Equal("HelloPlugin", loaded.Plugin!.GetType().Name);
|
||||||
|
loaded.Plugin.Disable();
|
||||||
|
loaded.LoadContext!.Unload();
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|
|
||||||
201
tests/AcDream.Core.Tests/Plugins/PluginSessionTests.cs
Normal file
201
tests/AcDream.Core.Tests/Plugins/PluginSessionTests.cs
Normal file
|
|
@ -0,0 +1,201 @@
|
||||||
|
using System.Text.Json;
|
||||||
|
using AcDream.Core.Plugins;
|
||||||
|
using AcDream.Core.Selection;
|
||||||
|
using AcDream.Plugin.Abstractions;
|
||||||
|
|
||||||
|
namespace AcDream.Core.Tests.Plugins;
|
||||||
|
|
||||||
|
public sealed class PluginSessionTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void AbsentAllowListLoadsEveryDiscoveredPlugin()
|
||||||
|
{
|
||||||
|
using var temporary = new TemporaryDirectory();
|
||||||
|
InstallFixture(temporary.Path, "alpha", "acdream.test.alpha");
|
||||||
|
InstallFixture(temporary.Path, "beta", "acdream.test.beta");
|
||||||
|
var statuses = new List<PluginSessionStatus>();
|
||||||
|
var plugins = new PluginSession(new StubHost(), statuses.Add);
|
||||||
|
|
||||||
|
plugins.Start([temporary.Path], allowList: null);
|
||||||
|
|
||||||
|
Assert.Equal(2, plugins.LoadedCount);
|
||||||
|
Assert.Equal(
|
||||||
|
["acdream.test.alpha", "acdream.test.beta"],
|
||||||
|
plugins.LoadedPluginIds);
|
||||||
|
Assert.All(
|
||||||
|
statuses,
|
||||||
|
status => Assert.Equal(PluginSessionStatusKind.Loaded, status.Kind));
|
||||||
|
ReleaseAndCollect(plugins);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AllowListIsCaseInsensitiveAndOneFailureDoesNotBlockAnotherPlugin()
|
||||||
|
{
|
||||||
|
using var temporary = new TemporaryDirectory();
|
||||||
|
InstallFixture(temporary.Path, "good", "acdream.test.good");
|
||||||
|
InstallBroken(temporary.Path, "broken", "acdream.test.broken");
|
||||||
|
var statuses = new List<PluginSessionStatus>();
|
||||||
|
var plugins = new PluginSession(new StubHost(), statuses.Add);
|
||||||
|
|
||||||
|
plugins.Start(
|
||||||
|
[temporary.Path],
|
||||||
|
[
|
||||||
|
"ACDREAM.TEST.BROKEN",
|
||||||
|
"ACDREAM.TEST.GOOD",
|
||||||
|
"acdream.test.missing",
|
||||||
|
]);
|
||||||
|
|
||||||
|
Assert.Equal(["acdream.test.good"], plugins.LoadedPluginIds);
|
||||||
|
Assert.Equal(
|
||||||
|
[
|
||||||
|
("ACDREAM.TEST.BROKEN", PluginSessionStatusKind.Failed),
|
||||||
|
("acdream.test.good", PluginSessionStatusKind.Loaded),
|
||||||
|
("acdream.test.missing", PluginSessionStatusKind.Failed),
|
||||||
|
],
|
||||||
|
statuses.Select(static status => (status.Plugin, status.Kind)));
|
||||||
|
Assert.All(
|
||||||
|
statuses.Where(static status => status.Kind == PluginSessionStatusKind.Failed),
|
||||||
|
status => Assert.False(string.IsNullOrWhiteSpace(status.Error)));
|
||||||
|
ReleaseAndCollect(plugins);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ExplicitEmptyAllowListLoadsNothing()
|
||||||
|
{
|
||||||
|
using var temporary = new TemporaryDirectory();
|
||||||
|
InstallFixture(temporary.Path, "fixture", "acdream.test.fixture");
|
||||||
|
var statuses = new List<PluginSessionStatus>();
|
||||||
|
using var plugins = new PluginSession(new StubHost(), statuses.Add);
|
||||||
|
|
||||||
|
plugins.Start([temporary.Path], []);
|
||||||
|
|
||||||
|
Assert.Equal(0, plugins.LoadedCount);
|
||||||
|
Assert.Empty(statuses);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ReleaseAndCollect(PluginSession plugins)
|
||||||
|
{
|
||||||
|
IReadOnlyList<WeakReference> contexts =
|
||||||
|
plugins.CaptureLoadContextWeakReferences();
|
||||||
|
plugins.Dispose();
|
||||||
|
for (int attempt = 0;
|
||||||
|
attempt < 10 && contexts.Any(static context => context.IsAlive);
|
||||||
|
attempt++)
|
||||||
|
{
|
||||||
|
GC.Collect();
|
||||||
|
GC.WaitForPendingFinalizers();
|
||||||
|
GC.Collect();
|
||||||
|
}
|
||||||
|
Assert.All(contexts, static context => Assert.False(context.IsAlive));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void InstallFixture(string root, string folder, string id)
|
||||||
|
{
|
||||||
|
string source = FixturePluginPath();
|
||||||
|
Assert.True(File.Exists(source), $"fixture DLL not found: {source}");
|
||||||
|
string pluginDirectory = Path.Combine(root, folder);
|
||||||
|
Directory.CreateDirectory(pluginDirectory);
|
||||||
|
string fileName = Path.GetFileName(source);
|
||||||
|
File.Copy(source, Path.Combine(pluginDirectory, fileName));
|
||||||
|
WriteManifest(pluginDirectory, id, fileName);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void InstallBroken(string root, string folder, string id)
|
||||||
|
{
|
||||||
|
string pluginDirectory = Path.Combine(root, folder);
|
||||||
|
Directory.CreateDirectory(pluginDirectory);
|
||||||
|
WriteManifest(pluginDirectory, id, "missing.dll");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void WriteManifest(
|
||||||
|
string directory,
|
||||||
|
string id,
|
||||||
|
string entryDll) =>
|
||||||
|
File.WriteAllText(
|
||||||
|
Path.Combine(directory, "plugin.json"),
|
||||||
|
JsonSerializer.Serialize(new
|
||||||
|
{
|
||||||
|
id,
|
||||||
|
displayName = id,
|
||||||
|
version = "1.0.0",
|
||||||
|
entryDll,
|
||||||
|
apiVersion = 1,
|
||||||
|
}));
|
||||||
|
|
||||||
|
private static string FixturePluginPath()
|
||||||
|
{
|
||||||
|
string configuration = new DirectoryInfo(AppContext.BaseDirectory)
|
||||||
|
.Parent!.Name;
|
||||||
|
string root = FindRepoRoot(AppContext.BaseDirectory);
|
||||||
|
return Path.Combine(
|
||||||
|
root,
|
||||||
|
"tests",
|
||||||
|
"AcDream.Core.Tests.Fixtures.HelloPlugin",
|
||||||
|
"bin",
|
||||||
|
configuration,
|
||||||
|
"net10.0",
|
||||||
|
"AcDream.Core.Tests.Fixtures.HelloPlugin.dll");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string FindRepoRoot(string start)
|
||||||
|
{
|
||||||
|
DirectoryInfo? directory = new(start);
|
||||||
|
while (directory is not null
|
||||||
|
&& !File.Exists(Path.Combine(directory.FullName, "AcDream.slnx")))
|
||||||
|
{
|
||||||
|
directory = directory.Parent;
|
||||||
|
}
|
||||||
|
return directory?.FullName
|
||||||
|
?? throw new InvalidOperationException("Repository root not found.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class StubHost : IPluginHost
|
||||||
|
{
|
||||||
|
public bool HasUi => false;
|
||||||
|
public IPluginLogger Log { get; } = new StubLogger();
|
||||||
|
public IGameState State { get; } = new StubState();
|
||||||
|
public IEvents Events { get; } = new StubEvents();
|
||||||
|
public ISelectionService Selection { get; } = new SelectionState();
|
||||||
|
public IUiRegistry Ui => NoOpUiRegistry.Instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class StubLogger : IPluginLogger
|
||||||
|
{
|
||||||
|
public void Info(string message) { }
|
||||||
|
public void Warn(string message) { }
|
||||||
|
public void Error(string message, Exception? exception = null) { }
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class StubState : IGameState
|
||||||
|
{
|
||||||
|
public IReadOnlyList<WorldEntitySnapshot> Entities => [];
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class StubEvents : IEvents
|
||||||
|
{
|
||||||
|
public event Action<WorldEntitySnapshot> EntitySpawned
|
||||||
|
{
|
||||||
|
add { }
|
||||||
|
remove { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class TemporaryDirectory : IDisposable
|
||||||
|
{
|
||||||
|
internal TemporaryDirectory()
|
||||||
|
{
|
||||||
|
Path = System.IO.Path.Combine(
|
||||||
|
System.IO.Path.GetTempPath(),
|
||||||
|
$"acdream-plugin-session-{Guid.NewGuid():N}");
|
||||||
|
Directory.CreateDirectory(Path);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal string Path { get; }
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (Directory.Exists(Path))
|
||||||
|
Directory.Delete(Path, recursive: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -24,6 +24,15 @@
|
||||||
<ProjectReference Include="..\..\src\AcDream.Launcher.Core\AcDream.Launcher.Core.csproj" />
|
<ProjectReference Include="..\..\src\AcDream.Launcher.Core\AcDream.Launcher.Core.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<!-- Build ordering only. Tests copy this DLL into a temporary plugin root
|
||||||
|
and the production loader loads it through a collectible ALC. -->
|
||||||
|
<ProjectReference Include="..\AcDream.Plugin.Tests.Fixtures.HostPlugin\AcDream.Plugin.Tests.Fixtures.HostPlugin.csproj">
|
||||||
|
<ReferenceOutputAssembly>false</ReferenceOutputAssembly>
|
||||||
|
<SkipGetTargetFrameworkProperties>true</SkipGetTargetFrameworkProperties>
|
||||||
|
</ProjectReference>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<Compile Include="..\Fixtures\campaign-la\LauncherCoreSessionConfigFixture.cs"
|
<Compile Include="..\Fixtures\campaign-la\LauncherCoreSessionConfigFixture.cs"
|
||||||
Link="Fixtures\LauncherCoreSessionConfigFixture.cs" />
|
Link="Fixtures\LauncherCoreSessionConfigFixture.cs" />
|
||||||
|
|
|
||||||
235
tests/AcDream.Headless.Tests/HeadlessPluginSessionTests.cs
Normal file
235
tests/AcDream.Headless.Tests/HeadlessPluginSessionTests.cs
Normal file
|
|
@ -0,0 +1,235 @@
|
||||||
|
using System.Text.Json;
|
||||||
|
using AcDream.Core.Net;
|
||||||
|
using AcDream.Core.Net.Messages;
|
||||||
|
using AcDream.Headless.Configuration;
|
||||||
|
using AcDream.Headless.Credentials;
|
||||||
|
using AcDream.Headless.Diagnostics;
|
||||||
|
using AcDream.Headless.Hosting;
|
||||||
|
using AcDream.Headless.Plugins;
|
||||||
|
using AcDream.Plugin.Abstractions;
|
||||||
|
|
||||||
|
namespace AcDream.Headless.Tests;
|
||||||
|
|
||||||
|
public sealed class HeadlessPluginSessionTests
|
||||||
|
{
|
||||||
|
private const string FixtureId = "acdream.test.host-fixture";
|
||||||
|
private const string BrokenId = "acdream.test.broken";
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ConfiguredSetBorrowsRuntimeUsesNoOpUiIsolatesFailureAndUnloads()
|
||||||
|
{
|
||||||
|
using var temporary = new TemporaryDirectory();
|
||||||
|
InstallFixture(temporary.Path, FixtureId);
|
||||||
|
InstallBrokenPlugin(temporary.Path, BrokenId);
|
||||||
|
var output = new StringWriter();
|
||||||
|
var diagnostics = new HeadlessDiagnosticWriter(output);
|
||||||
|
string statusPath = Path.Combine(temporary.Path, "status.jsonl");
|
||||||
|
var credential = new HeadlessCredentialSecret("fixture", "password");
|
||||||
|
using var session = new HeadlessSessionHost(
|
||||||
|
Descriptor([FixtureId.ToUpperInvariant(), BrokenId], statusPath),
|
||||||
|
credential,
|
||||||
|
diagnostics,
|
||||||
|
pluginRoots: [temporary.Path]);
|
||||||
|
HeadlessPluginSession plugins = session.Plugins;
|
||||||
|
_ = session.Runtime.EntityObjects.RegisterEntity(Spawn(0x50000001u, 1f));
|
||||||
|
|
||||||
|
Assert.Equal(1, plugins.LoadedCount);
|
||||||
|
Assert.False(plugins.Host.HasUi);
|
||||||
|
Assert.Same(NoOpUiRegistry.Instance, plugins.Host.Ui);
|
||||||
|
Assert.Same(
|
||||||
|
session.Runtime.ActionOwner.Selection,
|
||||||
|
plugins.Host.Selection);
|
||||||
|
WorldEntitySnapshot first = Assert.Single(plugins.Host.State.Entities);
|
||||||
|
Assert.Equal(1_000_000u, first.Id);
|
||||||
|
Assert.Equal(0x02000001u, first.SourceId);
|
||||||
|
|
||||||
|
_ = session.Runtime.EntityObjects.RegisterEntity(Spawn(0x50000002u, 2f));
|
||||||
|
Assert.Equal(2, plugins.Host.State.Entities.Count);
|
||||||
|
|
||||||
|
JsonElement[] statuses = ReadStatuses(statusPath);
|
||||||
|
Assert.Equal(["pluginLoaded", "pluginFailed"], EventNames(statuses));
|
||||||
|
Assert.Equal(FixtureId, statuses[0].GetProperty("plugin").GetString());
|
||||||
|
Assert.Equal(BrokenId, statuses[1].GetProperty("plugin").GetString());
|
||||||
|
Assert.Contains(
|
||||||
|
"entry dll not found",
|
||||||
|
statuses[1].GetProperty("error").GetString()!,
|
||||||
|
StringComparison.OrdinalIgnoreCase);
|
||||||
|
Assert.Contains("fixture-enabled:hasUi=False:entities=0", output.ToString());
|
||||||
|
|
||||||
|
WeakReference context = Assert.Single(
|
||||||
|
plugins.CaptureLoadContextWeakReferences());
|
||||||
|
session.Dispose();
|
||||||
|
Assert.Contains("fixture-disabled:entitiesSeen=2", output.ToString());
|
||||||
|
Assert.True(session.Runtime.CaptureOwnership().IsConverged);
|
||||||
|
Assert.True(credential.IsDisposed);
|
||||||
|
Collect(context);
|
||||||
|
Assert.False(context.IsAlive);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ExplicitEmptyConfiguredSetLoadsNone()
|
||||||
|
{
|
||||||
|
using var temporary = new TemporaryDirectory();
|
||||||
|
InstallFixture(temporary.Path, FixtureId);
|
||||||
|
var output = new StringWriter();
|
||||||
|
string statusPath = Path.Combine(temporary.Path, "status.jsonl");
|
||||||
|
var credential = new HeadlessCredentialSecret("fixture", "password");
|
||||||
|
|
||||||
|
using var session = new HeadlessSessionHost(
|
||||||
|
Descriptor([], statusPath),
|
||||||
|
credential,
|
||||||
|
new HeadlessDiagnosticWriter(output),
|
||||||
|
pluginRoots: [temporary.Path]);
|
||||||
|
|
||||||
|
Assert.Equal(0, session.Plugins.LoadedCount);
|
||||||
|
Assert.False(File.Exists(statusPath));
|
||||||
|
Assert.DoesNotContain("fixture-", output.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static HeadlessSessionDescriptor Descriptor(
|
||||||
|
List<string> plugins,
|
||||||
|
string statusPath) => new()
|
||||||
|
{
|
||||||
|
Id = "headless-session",
|
||||||
|
Endpoint = new HeadlessEndpointDescriptor
|
||||||
|
{
|
||||||
|
Host = "127.0.0.1",
|
||||||
|
Port = 9000,
|
||||||
|
},
|
||||||
|
Account = "account",
|
||||||
|
Character = new HeadlessCharacterSelector
|
||||||
|
{
|
||||||
|
Name = "Fixture",
|
||||||
|
},
|
||||||
|
Policy = new HeadlessBotPolicyDescriptor
|
||||||
|
{
|
||||||
|
Id = "idle",
|
||||||
|
},
|
||||||
|
Credential = new HeadlessCredentialReference
|
||||||
|
{
|
||||||
|
Provider = HeadlessCredentialProviderKind.Environment,
|
||||||
|
Reference = "FIXTURE_PASSWORD",
|
||||||
|
},
|
||||||
|
Plugins = plugins,
|
||||||
|
StatusFile = statusPath,
|
||||||
|
};
|
||||||
|
|
||||||
|
private static WorldSession.EntitySpawn Spawn(uint guid, float x) => new(
|
||||||
|
guid,
|
||||||
|
new CreateObject.ServerPosition(
|
||||||
|
0x01010001u,
|
||||||
|
x,
|
||||||
|
10f,
|
||||||
|
5f,
|
||||||
|
1f,
|
||||||
|
0f,
|
||||||
|
0f,
|
||||||
|
0f),
|
||||||
|
0x02000001u,
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
"Fixture",
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null);
|
||||||
|
|
||||||
|
private static JsonElement[] ReadStatuses(string path) =>
|
||||||
|
File.ReadAllLines(path)
|
||||||
|
.Select(static line => JsonDocument.Parse(line).RootElement.Clone())
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
private static string[] EventNames(IEnumerable<JsonElement> events) =>
|
||||||
|
events.Select(static item => item.GetProperty("e").GetString()!).ToArray();
|
||||||
|
|
||||||
|
private static void InstallFixture(string root, string id)
|
||||||
|
{
|
||||||
|
string source = FixtureAssemblyPath();
|
||||||
|
Assert.True(File.Exists(source), $"fixture DLL not found: {source}");
|
||||||
|
string pluginDirectory = Path.Combine(root, "host-fixture");
|
||||||
|
Directory.CreateDirectory(pluginDirectory);
|
||||||
|
string fileName = Path.GetFileName(source);
|
||||||
|
File.Copy(source, Path.Combine(pluginDirectory, fileName));
|
||||||
|
WriteManifest(pluginDirectory, id, fileName);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void InstallBrokenPlugin(string root, string id)
|
||||||
|
{
|
||||||
|
string pluginDirectory = Path.Combine(root, "broken");
|
||||||
|
Directory.CreateDirectory(pluginDirectory);
|
||||||
|
WriteManifest(pluginDirectory, id, "missing.dll");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void WriteManifest(
|
||||||
|
string directory,
|
||||||
|
string id,
|
||||||
|
string entryDll) =>
|
||||||
|
File.WriteAllText(
|
||||||
|
Path.Combine(directory, "plugin.json"),
|
||||||
|
JsonSerializer.Serialize(new
|
||||||
|
{
|
||||||
|
id,
|
||||||
|
displayName = "Host fixture",
|
||||||
|
version = "1.0.0",
|
||||||
|
entryDll,
|
||||||
|
apiVersion = 1,
|
||||||
|
}));
|
||||||
|
|
||||||
|
private static string FixtureAssemblyPath()
|
||||||
|
{
|
||||||
|
string configuration = new DirectoryInfo(AppContext.BaseDirectory)
|
||||||
|
.Parent!.Name;
|
||||||
|
string root = FindRepoRoot(AppContext.BaseDirectory);
|
||||||
|
return Path.Combine(
|
||||||
|
root,
|
||||||
|
"tests",
|
||||||
|
"AcDream.Plugin.Tests.Fixtures.HostPlugin",
|
||||||
|
"bin",
|
||||||
|
configuration,
|
||||||
|
"net10.0",
|
||||||
|
"AcDream.Plugin.Tests.Fixtures.HostPlugin.dll");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string FindRepoRoot(string start)
|
||||||
|
{
|
||||||
|
DirectoryInfo? directory = new(start);
|
||||||
|
while (directory is not null
|
||||||
|
&& !File.Exists(Path.Combine(directory.FullName, "AcDream.slnx")))
|
||||||
|
{
|
||||||
|
directory = directory.Parent;
|
||||||
|
}
|
||||||
|
return directory?.FullName
|
||||||
|
?? throw new InvalidOperationException("Repository root not found.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Collect(WeakReference reference)
|
||||||
|
{
|
||||||
|
for (int attempt = 0; attempt < 10 && reference.IsAlive; attempt++)
|
||||||
|
{
|
||||||
|
GC.Collect();
|
||||||
|
GC.WaitForPendingFinalizers();
|
||||||
|
GC.Collect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class TemporaryDirectory : IDisposable
|
||||||
|
{
|
||||||
|
internal TemporaryDirectory()
|
||||||
|
{
|
||||||
|
Path = System.IO.Path.Combine(
|
||||||
|
System.IO.Path.GetTempPath(),
|
||||||
|
$"acdream-headless-plugins-{Guid.NewGuid():N}");
|
||||||
|
Directory.CreateDirectory(Path);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal string Path { get; }
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (Directory.Exists(Path))
|
||||||
|
Directory.Delete(Path, recursive: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<LangVersion>latest</LangVersion>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||||
|
</PropertyGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<!-- The host owns this contract assembly. Keeping it out of the fixture's
|
||||||
|
output is required for IAcDreamPlugin type identity in the collectible
|
||||||
|
load context. -->
|
||||||
|
<ProjectReference Include="..\..\src\AcDream.Plugin.Abstractions\AcDream.Plugin.Abstractions.csproj">
|
||||||
|
<Private>false</Private>
|
||||||
|
<ExcludeAssets>runtime</ExcludeAssets>
|
||||||
|
</ProjectReference>
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
46
tests/AcDream.Plugin.Tests.Fixtures.HostPlugin/HostPlugin.cs
Normal file
46
tests/AcDream.Plugin.Tests.Fixtures.HostPlugin/HostPlugin.cs
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
using AcDream.Plugin.Abstractions;
|
||||||
|
|
||||||
|
namespace AcDream.Plugin.Tests.Fixtures.HostPlugin;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Cross-host LA5 fixture. It deliberately takes the same path on graphical
|
||||||
|
/// and no-window hosts: observe the capability, register UI, and subscribe to
|
||||||
|
/// gameplay events. A headless registry must make the UI call harmless without
|
||||||
|
/// retaining this instance in the default load context.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class HostPlugin : IAcDreamPlugin
|
||||||
|
{
|
||||||
|
private IPluginHost? _host;
|
||||||
|
private int _entitiesSeen;
|
||||||
|
|
||||||
|
public void Initialize(IPluginHost host)
|
||||||
|
{
|
||||||
|
_host = host ?? throw new ArgumentNullException(nameof(host));
|
||||||
|
host.Log.Info($"fixture-initialized:hasUi={host.HasUi}");
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Enable()
|
||||||
|
{
|
||||||
|
IPluginHost host = _host
|
||||||
|
?? throw new InvalidOperationException("The fixture was not initialized.");
|
||||||
|
host.Ui.AddMarkupPanel(
|
||||||
|
Path.Combine(AppContext.BaseDirectory, "fixture-panel.xml"),
|
||||||
|
this);
|
||||||
|
host.Events.EntitySpawned += OnEntitySpawned;
|
||||||
|
host.Log.Info(
|
||||||
|
$"fixture-enabled:hasUi={host.HasUi}:entities={host.State.Entities.Count}");
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Disable()
|
||||||
|
{
|
||||||
|
IPluginHost? host = _host;
|
||||||
|
if (host is null)
|
||||||
|
return;
|
||||||
|
host.Events.EntitySpawned -= OnEntitySpawned;
|
||||||
|
host.Log.Info($"fixture-disabled:entitiesSeen={_entitiesSeen}");
|
||||||
|
_host = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnEntitySpawned(WorldEntitySnapshot snapshot) =>
|
||||||
|
_entitiesSeen++;
|
||||||
|
}
|
||||||
|
|
@ -30,11 +30,13 @@ public sealed class SessionStatusWriterTests
|
||||||
new LiveSessionRosterEntry(0x50000002u, "Grey", 10u),
|
new LiveSessionRosterEntry(0x50000002u, "Grey", 10u),
|
||||||
]));
|
]));
|
||||||
writer.EnteredWorld("s1", 0x50000001u, "Ready");
|
writer.EnteredWorld("s1", 0x50000001u, "Ready");
|
||||||
|
writer.PluginLoaded("s1", "acdream.good");
|
||||||
|
writer.PluginFailed("s1", "acdream.bad", "enable failed");
|
||||||
writer.Disconnected("s1", "stopped");
|
writer.Disconnected("s1", "stopped");
|
||||||
writer.Exited("s1", 0, "disposed");
|
writer.Exited("s1", 0, "disposed");
|
||||||
|
|
||||||
string[] lines = File.ReadAllLines(file.Path);
|
string[] lines = File.ReadAllLines(file.Path);
|
||||||
Assert.Equal(6, lines.Length);
|
Assert.Equal(8, lines.Length);
|
||||||
|
|
||||||
JsonElement started = Parse(lines[0]);
|
JsonElement started = Parse(lines[0]);
|
||||||
Assert.Equal(1, started.GetProperty("v").GetInt32());
|
Assert.Equal(1, started.GetProperty("v").GetInt32());
|
||||||
|
|
@ -62,11 +64,20 @@ public sealed class SessionStatusWriterTests
|
||||||
Assert.Equal(0x50000001u, enteredWorld.GetProperty("characterId").GetUInt32());
|
Assert.Equal(0x50000001u, enteredWorld.GetProperty("characterId").GetUInt32());
|
||||||
Assert.Equal("Ready", enteredWorld.GetProperty("characterName").GetString());
|
Assert.Equal("Ready", enteredWorld.GetProperty("characterName").GetString());
|
||||||
|
|
||||||
JsonElement disconnected = Parse(lines[4]);
|
JsonElement pluginLoaded = Parse(lines[4]);
|
||||||
|
Assert.Equal("pluginLoaded", pluginLoaded.GetProperty("e").GetString());
|
||||||
|
Assert.Equal("acdream.good", pluginLoaded.GetProperty("plugin").GetString());
|
||||||
|
|
||||||
|
JsonElement pluginFailed = Parse(lines[5]);
|
||||||
|
Assert.Equal("pluginFailed", pluginFailed.GetProperty("e").GetString());
|
||||||
|
Assert.Equal("acdream.bad", pluginFailed.GetProperty("plugin").GetString());
|
||||||
|
Assert.Equal("enable failed", pluginFailed.GetProperty("error").GetString());
|
||||||
|
|
||||||
|
JsonElement disconnected = Parse(lines[6]);
|
||||||
Assert.Equal("disconnected", disconnected.GetProperty("e").GetString());
|
Assert.Equal("disconnected", disconnected.GetProperty("e").GetString());
|
||||||
Assert.Equal("stopped", disconnected.GetProperty("reason").GetString());
|
Assert.Equal("stopped", disconnected.GetProperty("reason").GetString());
|
||||||
|
|
||||||
JsonElement exited = Parse(lines[5]);
|
JsonElement exited = Parse(lines[7]);
|
||||||
Assert.Equal("exited", exited.GetProperty("e").GetString());
|
Assert.Equal("exited", exited.GetProperty("e").GetString());
|
||||||
Assert.Equal(0, exited.GetProperty("code").GetInt32());
|
Assert.Equal(0, exited.GetProperty("code").GetInt32());
|
||||||
Assert.Equal("disposed", exited.GetProperty("reason").GetString());
|
Assert.Equal("disposed", exited.GetProperty("reason").GetString());
|
||||||
|
|
@ -80,6 +91,8 @@ public sealed class SessionStatusWriterTests
|
||||||
|
|
||||||
writer.Started("s1");
|
writer.Started("s1");
|
||||||
writer.Connected("s1");
|
writer.Connected("s1");
|
||||||
|
writer.PluginLoaded("s1", "acdream.good");
|
||||||
|
writer.PluginFailed("s1", "acdream.bad", "failed");
|
||||||
writer.Disconnected("s1", "stopped");
|
writer.Disconnected("s1", "stopped");
|
||||||
writer.Exited("s1", 0, "disposed");
|
writer.Exited("s1", 0, "disposed");
|
||||||
|
|
||||||
|
|
@ -164,9 +177,10 @@ public sealed class SessionStatusWriterTests
|
||||||
/// credential material into this stream" contract: each event kind
|
/// credential material into this stream" contract: each event kind
|
||||||
/// serializes EXACTLY its pinned property set — the shared envelope
|
/// serializes EXACTLY its pinned property set — the shared envelope
|
||||||
/// (<c>v</c>/<c>e</c>/<c>t</c>/<c>sessionId</c>) plus that event's own
|
/// (<c>v</c>/<c>e</c>/<c>t</c>/<c>sessionId</c>) plus that event's own
|
||||||
/// named fields, nothing else. An extra property (a smuggled password,
|
/// named fields, nothing else. An extra credential-shaped or otherwise
|
||||||
/// or any other accidental field) fails this test by construction,
|
/// accidental property fails this test by construction. LA5's documented
|
||||||
/// regardless of what value it carries.
|
/// <c>pluginFailed.error</c> diagnostic is the one free-text value and its
|
||||||
|
/// caller remains responsible for never appending session secrets.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void EachEventSerializesExactlyItsPinnedPropertySetAndNothingElse()
|
public void EachEventSerializesExactlyItsPinnedPropertySetAndNothingElse()
|
||||||
|
|
@ -183,11 +197,13 @@ public sealed class SessionStatusWriterTests
|
||||||
11,
|
11,
|
||||||
[new LiveSessionRosterEntry(0x50000001u, "Ready", 0u)]));
|
[new LiveSessionRosterEntry(0x50000001u, "Ready", 0u)]));
|
||||||
writer.EnteredWorld("bot", 0x50000001u, "Ready");
|
writer.EnteredWorld("bot", 0x50000001u, "Ready");
|
||||||
|
writer.PluginLoaded("bot", "acdream.good");
|
||||||
|
writer.PluginFailed("bot", "acdream.bad", "enable failed");
|
||||||
writer.Disconnected("bot", "stopped");
|
writer.Disconnected("bot", "stopped");
|
||||||
writer.Exited("bot", 0, "disposed");
|
writer.Exited("bot", 0, "disposed");
|
||||||
|
|
||||||
string[] lines = File.ReadAllLines(file.Path);
|
string[] lines = File.ReadAllLines(file.Path);
|
||||||
Assert.Equal(6, lines.Length);
|
Assert.Equal(8, lines.Length);
|
||||||
|
|
||||||
AssertExactProperties(lines[0], "v", "e", "t", "sessionId");
|
AssertExactProperties(lines[0], "v", "e", "t", "sessionId");
|
||||||
AssertExactProperties(lines[1], "v", "e", "t", "sessionId");
|
AssertExactProperties(lines[1], "v", "e", "t", "sessionId");
|
||||||
|
|
@ -196,8 +212,11 @@ public sealed class SessionStatusWriterTests
|
||||||
"v", "e", "t", "sessionId", "accountName", "slotCount", "characters");
|
"v", "e", "t", "sessionId", "accountName", "slotCount", "characters");
|
||||||
AssertExactProperties(
|
AssertExactProperties(
|
||||||
lines[3], "v", "e", "t", "sessionId", "characterId", "characterName");
|
lines[3], "v", "e", "t", "sessionId", "characterId", "characterName");
|
||||||
AssertExactProperties(lines[4], "v", "e", "t", "sessionId", "reason");
|
AssertExactProperties(lines[4], "v", "e", "t", "sessionId", "plugin");
|
||||||
AssertExactProperties(lines[5], "v", "e", "t", "sessionId", "code", "reason");
|
AssertExactProperties(
|
||||||
|
lines[5], "v", "e", "t", "sessionId", "plugin", "error");
|
||||||
|
AssertExactProperties(lines[6], "v", "e", "t", "sessionId", "reason");
|
||||||
|
AssertExactProperties(lines[7], "v", "e", "t", "sessionId", "code", "reason");
|
||||||
|
|
||||||
// The nested characters[] entries are exact too — the exact shape a
|
// The nested characters[] entries are exact too — the exact shape a
|
||||||
// password could otherwise be smuggled through.
|
// password could otherwise be smuggled through.
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue