feat(plugins): complete Campaign LA5 cross-host hosting
This commit is contained in:
parent
6c4cd2bbc6
commit
95f4be94db
26 changed files with 1630 additions and 99 deletions
150
src/AcDream.Headless/Plugins/HeadlessPluginHost.cs
Normal file
150
src/AcDream.Headless/Plugins/HeadlessPluginHost.cs
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
using AcDream.Plugin.Abstractions;
|
||||
using AcDream.Runtime;
|
||||
|
||||
namespace AcDream.Headless.Plugins;
|
||||
|
||||
/// <summary>
|
||||
/// No-window plugin surface over one exact <see cref="GameRuntime"/>. State is
|
||||
/// projected on demand from Runtime's canonical entity view, events come from
|
||||
/// Runtime's ordered event source, and selection is the exact J5 action owner;
|
||||
/// this adapter owns no gameplay mirror.
|
||||
/// </summary>
|
||||
internal sealed class HeadlessPluginHost
|
||||
: IPluginHost,
|
||||
IGameState,
|
||||
IEvents,
|
||||
IRuntimeEventObserver,
|
||||
IDisposable
|
||||
{
|
||||
private readonly GameRuntime _runtime;
|
||||
private readonly IDisposable _eventSubscription;
|
||||
private readonly object _eventGate = new();
|
||||
private Action<WorldEntitySnapshot>? _entitySpawned;
|
||||
private bool _disposed;
|
||||
|
||||
internal HeadlessPluginHost(
|
||||
GameRuntime runtime,
|
||||
IPluginLogger logger)
|
||||
{
|
||||
_runtime = runtime ?? throw new ArgumentNullException(nameof(runtime));
|
||||
Log = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_eventSubscription = runtime.Subscribe(this);
|
||||
}
|
||||
|
||||
public bool HasUi => false;
|
||||
public IPluginLogger Log { get; }
|
||||
public IGameState State => this;
|
||||
public IEvents Events => this;
|
||||
public ISelectionService Selection => _runtime.ActionOwner.Selection;
|
||||
public IUiRegistry Ui => NoOpUiRegistry.Instance;
|
||||
|
||||
/// <summary>
|
||||
/// Immutable point-in-time values produced directly from Runtime on each
|
||||
/// read. The caller owns the returned snapshot list; this host retains no
|
||||
/// entity collection and therefore cannot become a second gameplay owner.
|
||||
/// </summary>
|
||||
public IReadOnlyList<WorldEntitySnapshot> Entities
|
||||
{
|
||||
get
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
var visitor = new SnapshotVisitor(_runtime);
|
||||
_runtime.Entities.Visit(visitor);
|
||||
return visitor.Snapshots;
|
||||
}
|
||||
}
|
||||
|
||||
public event Action<WorldEntitySnapshot> EntitySpawned
|
||||
{
|
||||
add
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(value);
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
lock (_eventGate)
|
||||
_entitySpawned += value;
|
||||
|
||||
// Match the graphical WorldEvents contract: a late subscriber
|
||||
// immediately observes the canonical world that exists now.
|
||||
foreach (WorldEntitySnapshot snapshot in Entities)
|
||||
Invoke(value, snapshot);
|
||||
}
|
||||
remove
|
||||
{
|
||||
if (value is null)
|
||||
return;
|
||||
lock (_eventGate)
|
||||
_entitySpawned -= value;
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
return;
|
||||
_eventSubscription.Dispose();
|
||||
_disposed = true;
|
||||
lock (_eventGate)
|
||||
_entitySpawned = null;
|
||||
}
|
||||
|
||||
public void OnEntity(in RuntimeEntityDelta delta)
|
||||
{
|
||||
if (_disposed || delta.Change != RuntimeEntityChange.Registered)
|
||||
return;
|
||||
Action<WorldEntitySnapshot>? handlers;
|
||||
lock (_eventGate)
|
||||
handlers = _entitySpawned;
|
||||
if (handlers is null)
|
||||
return;
|
||||
|
||||
WorldEntitySnapshot snapshot = Convert(_runtime, delta.Entity);
|
||||
foreach (Action<WorldEntitySnapshot> handler
|
||||
in handlers.GetInvocationList().Cast<Action<WorldEntitySnapshot>>())
|
||||
{
|
||||
Invoke(handler, snapshot);
|
||||
}
|
||||
}
|
||||
|
||||
public void OnLifecycle(in RuntimeLifecycleDelta delta) { }
|
||||
public void OnCommand(in RuntimeCommandDelta delta) { }
|
||||
public void OnInventory(in RuntimeInventoryDelta delta) { }
|
||||
public void OnChat(in RuntimeChatDelta delta) { }
|
||||
public void OnMovement(in RuntimeMovementDelta delta) { }
|
||||
public void OnPortal(in RuntimePortalDelta delta) { }
|
||||
public void OnCombat(in RuntimeCombatDelta delta) { }
|
||||
|
||||
private static WorldEntitySnapshot Convert(
|
||||
GameRuntime runtime,
|
||||
in RuntimeEntitySnapshot entity)
|
||||
{
|
||||
uint sourceId = runtime.EntityObjects.Entities.TryGetActive(
|
||||
entity.Identity.ServerGuid,
|
||||
out AcDream.Runtime.Entities.RuntimeEntityRecord record)
|
||||
? record.Snapshot.SetupTableId ?? 0u
|
||||
: 0u;
|
||||
return new WorldEntitySnapshot(
|
||||
entity.Identity.LocalEntityId,
|
||||
sourceId,
|
||||
entity.Position?.Frame.Origin ?? default,
|
||||
entity.Position?.Frame.Orientation
|
||||
?? System.Numerics.Quaternion.Identity);
|
||||
}
|
||||
|
||||
private static void Invoke(
|
||||
Action<WorldEntitySnapshot> handler,
|
||||
WorldEntitySnapshot snapshot)
|
||||
{
|
||||
try { handler(snapshot); }
|
||||
catch { }
|
||||
}
|
||||
|
||||
private sealed class SnapshotVisitor(GameRuntime runtime)
|
||||
: IRuntimeEntityVisitor
|
||||
{
|
||||
internal List<WorldEntitySnapshot> Snapshots { get; } =
|
||||
new(runtime.Entities.Count);
|
||||
|
||||
public void Visit(in RuntimeEntitySnapshot entity) =>
|
||||
Snapshots.Add(Convert(runtime, entity));
|
||||
}
|
||||
}
|
||||
43
src/AcDream.Headless/Plugins/HeadlessPluginLogger.cs
Normal file
43
src/AcDream.Headless/Plugins/HeadlessPluginLogger.cs
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
using AcDream.Headless.Diagnostics;
|
||||
using AcDream.Plugin.Abstractions;
|
||||
|
||||
namespace AcDream.Headless.Plugins;
|
||||
|
||||
internal sealed class HeadlessPluginLogger : IPluginLogger
|
||||
{
|
||||
private readonly HeadlessDiagnosticWriter _diagnostics;
|
||||
private readonly string _sessionId;
|
||||
private readonly Func<ulong> _generation;
|
||||
|
||||
internal HeadlessPluginLogger(
|
||||
HeadlessDiagnosticWriter diagnostics,
|
||||
string sessionId,
|
||||
Func<ulong> generation)
|
||||
{
|
||||
_diagnostics = diagnostics
|
||||
?? throw new ArgumentNullException(nameof(diagnostics));
|
||||
_sessionId = sessionId
|
||||
?? throw new ArgumentNullException(nameof(sessionId));
|
||||
_generation = generation
|
||||
?? throw new ArgumentNullException(nameof(generation));
|
||||
}
|
||||
|
||||
public void Info(string message) => Write("info", message);
|
||||
public void Warn(string message) => Write("warn", message);
|
||||
|
||||
public void Error(string message, Exception? exception = null)
|
||||
{
|
||||
if (exception is not null)
|
||||
{
|
||||
_diagnostics.Failure(_sessionId, "plugin", exception);
|
||||
return;
|
||||
}
|
||||
Write("error", message);
|
||||
}
|
||||
|
||||
private void Write(string level, string message) =>
|
||||
_diagnostics.Message(
|
||||
_sessionId,
|
||||
$"plugin-{level}:{message}",
|
||||
_generation());
|
||||
}
|
||||
109
src/AcDream.Headless/Plugins/HeadlessPluginSession.cs
Normal file
109
src/AcDream.Headless/Plugins/HeadlessPluginSession.cs
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
using AcDream.Core.Plugins;
|
||||
using AcDream.Headless.Diagnostics;
|
||||
using AcDream.Plugin.Abstractions;
|
||||
using AcDream.Runtime;
|
||||
using AcDream.Runtime.Session;
|
||||
|
||||
namespace AcDream.Headless.Plugins;
|
||||
|
||||
/// <summary>
|
||||
/// Headless composition wrapper that keeps plugin disable/unsubscribe/unload
|
||||
/// ahead of canonical Runtime disposal.
|
||||
/// </summary>
|
||||
internal sealed class HeadlessPluginSession : IDisposable
|
||||
{
|
||||
private readonly HeadlessPluginHost _host;
|
||||
private readonly PluginSession _plugins;
|
||||
private int _disposeStage;
|
||||
private bool _disposed;
|
||||
|
||||
private HeadlessPluginSession(
|
||||
HeadlessPluginHost host,
|
||||
PluginSession plugins)
|
||||
{
|
||||
_host = host;
|
||||
_plugins = plugins;
|
||||
}
|
||||
|
||||
internal int LoadedCount => _plugins.LoadedCount;
|
||||
internal IPluginHost Host => _host;
|
||||
|
||||
internal IReadOnlyList<WeakReference> CaptureLoadContextWeakReferences() =>
|
||||
_plugins.CaptureLoadContextWeakReferences();
|
||||
|
||||
internal static HeadlessPluginSession Start(
|
||||
GameRuntime runtime,
|
||||
HeadlessDiagnosticWriter diagnostics,
|
||||
SessionStatusWriter statusWriter,
|
||||
string sessionId,
|
||||
IEnumerable<string> roots,
|
||||
IReadOnlyList<string>? allowList)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(runtime);
|
||||
ArgumentNullException.ThrowIfNull(diagnostics);
|
||||
ArgumentNullException.ThrowIfNull(statusWriter);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(sessionId);
|
||||
ArgumentNullException.ThrowIfNull(roots);
|
||||
|
||||
var host = new HeadlessPluginHost(
|
||||
runtime,
|
||||
new HeadlessPluginLogger(
|
||||
diagnostics,
|
||||
sessionId,
|
||||
() => runtime.Generation.Value));
|
||||
var plugins = new PluginSession(
|
||||
host,
|
||||
status => Report(statusWriter, sessionId, status));
|
||||
try
|
||||
{
|
||||
plugins.Start(roots, allowList);
|
||||
return new HeadlessPluginSession(host, plugins);
|
||||
}
|
||||
catch
|
||||
{
|
||||
plugins.Dispose();
|
||||
host.Dispose();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
return;
|
||||
while (!_disposed)
|
||||
{
|
||||
switch (_disposeStage)
|
||||
{
|
||||
case 0:
|
||||
_plugins.Dispose();
|
||||
_disposeStage++;
|
||||
break;
|
||||
case 1:
|
||||
_host.Dispose();
|
||||
_disposeStage++;
|
||||
_disposed = true;
|
||||
break;
|
||||
default:
|
||||
throw new InvalidOperationException(
|
||||
"Unknown headless plugin teardown stage.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void Report(
|
||||
SessionStatusWriter writer,
|
||||
string sessionId,
|
||||
PluginSessionStatus status)
|
||||
{
|
||||
if (status.Kind == PluginSessionStatusKind.Loaded)
|
||||
{
|
||||
writer.PluginLoaded(sessionId, status.Plugin);
|
||||
return;
|
||||
}
|
||||
writer.PluginFailed(
|
||||
sessionId,
|
||||
status.Plugin,
|
||||
status.Error ?? "plugin failed");
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue