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));
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue