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

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