313 lines
11 KiB
C#
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();
|
|
}
|
|
}
|