acdream/src/AcDream.Headless/Plugins/HeadlessPluginHost.cs

313 lines
11 KiB
C#

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 readonly List<Subscription> _subscriptions = [];
private Subscription[] _liveSnapshot = [];
private bool _disposed;
private readonly record struct ReplayEntity(
RuntimeEntityIdentity Identity,
WorldEntitySnapshot Snapshot);
private sealed class Subscription(Action<WorldEntitySnapshot> handler)
{
internal Action<WorldEntitySnapshot> Handler { get; } = handler;
internal Queue<ReplayEntity> Pending { get; } = new();
internal HashSet<RuntimeEntityIdentity> Delivered { get; } = [];
internal bool Replaying { get; set; } = true;
internal bool Active { get; set; } = true;
}
internal HeadlessPluginHost(
GameRuntime runtime,
IPluginLogger logger,
IPluginCommandRegistry? commands = null)
{
_runtime = runtime ?? throw new ArgumentNullException(nameof(runtime));
Log = logger ?? throw new ArgumentNullException(nameof(logger));
Commands = commands ?? NoOpPluginCommandRegistry.Instance;
_eventSubscription = runtime.Subscribe(this);
}
public bool HasUi => false;
public IPluginLogger Log { get; }
public IPluginCommandRegistry Commands { get; }
public IGameState State => this;
public IEvents Events => this;
public ISelectionService Selection => _runtime.ActionOwner.Selection;
/// <summary>
/// No live-session projection is bound on this host yet, so automation is
/// inert rather than absent -- a plugin keeps one code path and checks
/// IsAvailable.
/// </summary>
public IAutomationSurface Automation => NoOpAutomationSurface.Instance;
public IUiRegistry Ui => NoOpUiRegistry.Instance;
/// <summary>
/// Accepted but never raised on this host: the headless bot loop drives its
/// policies through Runtime's own scheduler, not through a plugin tick, so
/// there is no update to forward. A plugin written against the graphical
/// host therefore loads and runs here, it simply never ticks. Wiring it is a
/// Slice-K scheduler change rather than a plugin-API one.
/// </summary>
/// <remarks>
/// Written with explicit accessors rather than as a field-like event on
/// purpose: a field-like event that is never raised is a compiler warning,
/// and this project builds warnings as errors. Discarding here states the
/// intent instead of suppressing the diagnostic.
/// </remarks>
public event Action<double> Tick
{
add { _ = value; }
remove { _ = value; }
}
/// <summary>Test-only barrier invoked after the first replay item is
/// captured while Runtime's exact active-membership read lease is still
/// held.</summary>
internal Action? ReplayCapturedForTest { get; set; }
/// <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.Items.Select(static item => item.Snapshot).ToArray();
}
}
/// <summary>
/// Campaign QT slice QT6. Same borrow-don't-own shape as
/// <see cref="Entities"/>: projected from the canonical tracker on read.
/// Names and status text are empty here — a headless host has no dat
/// access — while every numeric field a bot actually branches on
/// (contract id, stage, progress) is present.
/// </summary>
public IReadOnlyList<ContractSnapshot> Contracts
{
get
{
ObjectDisposedException.ThrowIf(_disposed, this);
return AcDream.Runtime.Gameplay.ContractPluginProjection.Project(
_runtime.ContractsOwner.View,
catalog: null,
now: DateTime.UtcNow);
}
}
public event Action<WorldEntitySnapshot> EntitySpawned
{
add
{
ArgumentNullException.ThrowIfNull(value);
ObjectDisposedException.ThrowIf(_disposed, this);
var subscription = new Subscription(value);
lock (_eventGate)
{
ObjectDisposedException.ThrowIf(_disposed, this);
_subscriptions.Add(subscription);
}
// Arm the pending queue before borrowing Runtime's snapshot. This
// avoids a host-lock/Runtime-lock inversion while the identity
// dedup below collapses any registration present in both views.
var visitor = new SnapshotVisitor(
_runtime,
ReplayCapturedForTest);
_runtime.Entities.Visit(visitor);
ReplayEntity[] replay = visitor.Items.ToArray();
foreach (ReplayEntity item in replay)
{
lock (_eventGate)
{
if (!subscription.Active)
return;
if (!_runtime.Entities.TryGet(
item.Identity.ServerGuid,
out RuntimeEntitySnapshot current)
|| current.Identity != item.Identity
|| !subscription.Delivered.Add(item.Identity))
{
continue;
}
}
Invoke(subscription.Handler, item.Snapshot);
}
while (true)
{
ReplayEntity pending;
lock (_eventGate)
{
if (!subscription.Active)
return;
if (!subscription.Pending.TryDequeue(out pending))
{
subscription.Replaying = false;
subscription.Delivered.Clear();
RebuildLiveSnapshotLocked();
return;
}
if (!subscription.Delivered.Add(pending.Identity))
continue;
}
Invoke(subscription.Handler, pending.Snapshot);
}
}
remove
{
if (value is null)
return;
lock (_eventGate)
{
for (int index = _subscriptions.Count - 1; index >= 0; index--)
{
Subscription subscription = _subscriptions[index];
if (subscription.Handler != value)
continue;
subscription.Active = false;
subscription.Pending.Clear();
subscription.Delivered.Clear();
_subscriptions.RemoveAt(index);
if (!subscription.Replaying)
RebuildLiveSnapshotLocked();
break;
}
}
}
}
public void Dispose()
{
if (_disposed)
return;
lock (_eventGate)
{
_disposed = true;
foreach (Subscription subscription in _subscriptions)
{
subscription.Active = false;
subscription.Pending.Clear();
subscription.Delivered.Clear();
}
_subscriptions.Clear();
_liveSnapshot = [];
}
_eventSubscription.Dispose();
}
public void OnEntity(in RuntimeEntityDelta delta)
{
if (delta.Change != RuntimeEntityChange.Registered)
return;
Subscription[] toNotify;
var pending = new ReplayEntity(
delta.Entity.Identity,
Convert(_runtime, delta.Entity));
lock (_eventGate)
{
if (_disposed)
return;
foreach (Subscription subscription in _subscriptions)
{
if (subscription.Active && subscription.Replaying)
subscription.Pending.Enqueue(pending);
}
toNotify = _liveSnapshot;
}
if (toNotify.Length == 0)
return;
foreach (Subscription subscription in toNotify)
Invoke(subscription.Handler, pending.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,
Action? captureBarrier = null)
: IRuntimeEntityVisitor
{
private Action? _captureBarrier = captureBarrier;
internal List<ReplayEntity> Items { get; } =
new(runtime.Entities.Count);
public void Visit(in RuntimeEntitySnapshot entity)
{
Items.Add(new ReplayEntity(
entity.Identity,
Convert(runtime, entity)));
Interlocked.Exchange(ref _captureBarrier, null)?.Invoke();
}
}
private void RebuildLiveSnapshotLocked()
{
_liveSnapshot = _subscriptions
.Where(static subscription =>
subscription.Active && !subscription.Replaying)
.ToArray();
}
}