feat(plugins): complete Campaign LA5 cross-host hosting

This commit is contained in:
Erik 2026-08-14 18:12:59 +02:00
parent 6c4cd2bbc6
commit 95f4be94db
26 changed files with 1630 additions and 99 deletions

View file

@ -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>

View file

@ -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

View file

@ -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; }

View 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");
}
}

View file

@ -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();
} }

View file

@ -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;

View file

@ -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);
} }
} }

View 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;
}

View file

@ -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
{ {

View file

@ -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",

View file

@ -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)

View 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));
}
}

View 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());
}

View 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");
}
}

View file

@ -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; }

View file

@ -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)
{
}
}

View file

@ -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)

View file

@ -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" />

View 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);
}
}
}

View file

@ -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]

View 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);
}
}
}

View file

@ -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" />

View 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);
}
}
}

View file

@ -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>

View 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++;
}

View file

@ -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.