using AcDream.Plugin.Abstractions; using AcDream.Runtime; namespace AcDream.Headless.Plugins; /// /// No-window plugin surface over one exact . 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. /// 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 _subscriptions = []; private Subscription[] _liveSnapshot = []; private bool _disposed; private readonly record struct ReplayEntity( RuntimeEntityIdentity Identity, WorldEntitySnapshot Snapshot); private sealed class Subscription(Action handler) { internal Action Handler { get; } = handler; internal Queue Pending { get; } = new(); internal HashSet 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; /// /// 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. /// public IAutomationSurface Automation => NoOpAutomationSurface.Instance; public IUiRegistry Ui => NoOpUiRegistry.Instance; /// /// 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. /// /// /// 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. /// public event Action Tick { add { _ = value; } remove { _ = value; } } /// Test-only barrier invoked after the first replay item is /// captured while Runtime's exact active-membership read lease is still /// held. internal Action? ReplayCapturedForTest { get; set; } /// /// 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. /// public IReadOnlyList Entities { get { ObjectDisposedException.ThrowIf(_disposed, this); var visitor = new SnapshotVisitor(_runtime); _runtime.Entities.Visit(visitor); return visitor.Items.Select(static item => item.Snapshot).ToArray(); } } /// /// Campaign QT slice QT6. Same borrow-don't-own shape as /// : 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. /// public IReadOnlyList Contracts { get { ObjectDisposedException.ThrowIf(_disposed, this); return AcDream.Runtime.Gameplay.ContractPluginProjection.Project( _runtime.ContractsOwner.View, catalog: null, now: DateTime.UtcNow); } } public event Action 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 handler, WorldEntitySnapshot snapshot) { try { handler(snapshot); } catch { } } private sealed class SnapshotVisitor( GameRuntime runtime, Action? captureBarrier = null) : IRuntimeEntityVisitor { private Action? _captureBarrier = captureBarrier; internal List 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(); } }