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

@ -25,6 +25,15 @@
<ProjectReference Include="..\..\src\AcDream.Launcher.Core\AcDream.Launcher.Core.csproj" />
</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>
<Compile Include="..\Fixtures\campaign-la\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
{
public bool HasUi => true;
public IPluginLogger Log { get; } = new StubLogger();
public IGameState State { get; } = new StubState();
public IEvents Events { get; } = new StubEvents();
@ -84,6 +85,8 @@ public class PluginLoaderTests
Assert.True(loaded.Success);
Assert.NotNull(loaded.Plugin);
Assert.Equal("HelloPlugin", loaded.Plugin!.GetType().Name);
loaded.Plugin.Disable();
loaded.LoadContext!.Unload();
}
[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" />
</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>
<Compile Include="..\Fixtures\campaign-la\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),
]));
writer.EnteredWorld("s1", 0x50000001u, "Ready");
writer.PluginLoaded("s1", "acdream.good");
writer.PluginFailed("s1", "acdream.bad", "enable failed");
writer.Disconnected("s1", "stopped");
writer.Exited("s1", 0, "disposed");
string[] lines = File.ReadAllLines(file.Path);
Assert.Equal(6, lines.Length);
Assert.Equal(8, lines.Length);
JsonElement started = Parse(lines[0]);
Assert.Equal(1, started.GetProperty("v").GetInt32());
@ -62,11 +64,20 @@ public sealed class SessionStatusWriterTests
Assert.Equal(0x50000001u, enteredWorld.GetProperty("characterId").GetUInt32());
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("stopped", disconnected.GetProperty("reason").GetString());
JsonElement exited = Parse(lines[5]);
JsonElement exited = Parse(lines[7]);
Assert.Equal("exited", exited.GetProperty("e").GetString());
Assert.Equal(0, exited.GetProperty("code").GetInt32());
Assert.Equal("disposed", exited.GetProperty("reason").GetString());
@ -80,6 +91,8 @@ public sealed class SessionStatusWriterTests
writer.Started("s1");
writer.Connected("s1");
writer.PluginLoaded("s1", "acdream.good");
writer.PluginFailed("s1", "acdream.bad", "failed");
writer.Disconnected("s1", "stopped");
writer.Exited("s1", 0, "disposed");
@ -164,9 +177,10 @@ public sealed class SessionStatusWriterTests
/// credential material into this stream" contract: each event kind
/// 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
/// named fields, nothing else. An extra property (a smuggled password,
/// or any other accidental field) fails this test by construction,
/// regardless of what value it carries.
/// named fields, nothing else. An extra credential-shaped or otherwise
/// accidental property fails this test by construction. LA5's documented
/// <c>pluginFailed.error</c> diagnostic is the one free-text value and its
/// caller remains responsible for never appending session secrets.
/// </summary>
[Fact]
public void EachEventSerializesExactlyItsPinnedPropertySetAndNothingElse()
@ -183,11 +197,13 @@ public sealed class SessionStatusWriterTests
11,
[new LiveSessionRosterEntry(0x50000001u, "Ready", 0u)]));
writer.EnteredWorld("bot", 0x50000001u, "Ready");
writer.PluginLoaded("bot", "acdream.good");
writer.PluginFailed("bot", "acdream.bad", "enable failed");
writer.Disconnected("bot", "stopped");
writer.Exited("bot", 0, "disposed");
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[1], "v", "e", "t", "sessionId");
@ -196,8 +212,11 @@ public sealed class SessionStatusWriterTests
"v", "e", "t", "sessionId", "accountName", "slotCount", "characters");
AssertExactProperties(
lines[3], "v", "e", "t", "sessionId", "characterId", "characterName");
AssertExactProperties(lines[4], "v", "e", "t", "sessionId", "reason");
AssertExactProperties(lines[5], "v", "e", "t", "sessionId", "code", "reason");
AssertExactProperties(lines[4], "v", "e", "t", "sessionId", "plugin");
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
// password could otherwise be smuggled through.