acdream/src/AcDream.Headless/Plugins/HeadlessPluginHost.cs
Erik 2040f2bcfb fix(vt): F host-composed VtankProfiles storage replaces raw path string
Item F (slice-1 fix round). IPluginHost.VtankProfileDirectory handed the
plugin a raw string path and told it to fall back to its own
System.IO-based portable default when null — a plugin reading and
resolving filesystem paths itself, which is exactly the seam the rest of
IPluginHost.Storage deliberately avoids (Core.Plugins.ScopedPluginHost
scopes/validates every key; the plugin never sees a path).

- IPluginHost: VtankProfileDirectory (string?) deleted; new VtankProfiles
  (IPluginStorage, defaults to NoOpPluginStorage) added — a second,
  UNSCOPED storage instance (unlike Storage, which Core scopes per
  plugin manifest id) rooted at a host-composed VTank-compatible
  directory.
- ScopedPluginHost.VtankProfiles forwards _inner.VtankProfiles directly
  (no scoping — it names one shared external location, not per-plugin
  data). New PluginSessionTests.ScopedHostForwardsVtankProfilesUnscoped
  proves the forwarded instance is the exact same object (Assert.Same),
  not a wrapper.
- AppPluginHost/Program.cs: new vtankProfiles constructor parameter,
  composed as FilePluginStorage(runtimeOptions.VtankProfileDirectoryOverride
  ?? Path.Combine(applicationPaths.DataDirectory, "vtank")).
- RuntimeOptions.VtankProfileDirectoryOverride: new init-only property
  parsed from ACDREAM_VTANK_PROFILE_DIR (row added to
  docs/launch-options.md, side-effects column states the redirect is the
  only effect and documents the NullIfEmpty whitespace-not-special-cased
  quirk it shares with every other path-override flag). New
  RuntimeOptionsTests.VtankProfileDirectoryOverrideIsNullUnlessSet.
- FilePluginStorage.List(prefix): empty prefix now means "the storage
  root itself" instead of throwing (Resolve() rejects empty/whitespace
  keys, which is correct for every OTHER caller but wrong for "list
  everything" — VtankProfileDirectory needs exactly that).
- Headless: HeadlessPluginHost gained the same VtankProfiles
  property/constructor param, threaded through HeadlessPluginSession.Create
  -> HeadlessSessionHost -> HeadlessProcessHost, composed from the new
  HeadlessPathSet.VtankProfilesDirectory (<DataDirectory>/vtank, no
  ACDREAM_VTANK_PROFILE_DIR-equivalent override — Headless path overrides
  are HeadlessPathOverrides/CLI flags, not env vars). A small
  AcDream.Headless.Plugins.FilePluginStorage duplicates the App
  implementation byte-for-byte (Headless does not reference AcDream.App
  and no shared "platform plugins" library exists yet to host one copy;
  documented as a reasonable future consolidation, not required here).
- VtankProfileDirectory.cs rewritten: Resolve/PortableDefault deleted
  outright (no more System.IO, no plugin-owned portable-default fallback);
  ListSettingsProfiles/ListNavigationProfiles/ListMetaProfiles now take
  IPluginStorage and enumerate through EnumerateFileNames, which calls
  storage.List(string.Empty) and skips any key containing '/' (VTank's
  profile directory is flat; a nested key from some other IPluginStorage
  implementation is not a profile file). VtankProfileDirectoryTests
  rewritten against an in-memory IPluginStorage fake instead of real
  temp directories; new NestedPathKeysAreNotTreatedAsProfileFiles pins
  that skip. The prior Resolve/PortableDefault-specific tests (Linux-path
  guarantee, host-override-vs-portable-default) are superseded by
  RuntimeOptionsTests.VtankProfileDirectoryOverrideIsNullUnlessSet plus
  the RuntimeOptions.FromEnvironment Path.Combine-only composition in
  Program.cs.
- docs/architecture/acdream-architecture.md: one sentence in the
  Storage/List(prefix) paragraph naming VtankProfiles as the second,
  unscoped storage.

No production caller of VtankProfileDirectory's listing methods exists
yet (A2's foundation is not wired into MossTankProfileStore/
MossTankMetaProfileStore/MossTankRouteProfileStore's own selection —
per that slice's own ledger note), so this is a contract + plumbing
change with no MossTank runtime behavior change.

MossTank suite: 562/562. Core.Tests (Plugin filter): 50/50. App.Tests
(Plugin|LaunchOptions|RuntimeOptions filter): 135/135. Headless.Tests:
173/174 (the one failure, HeadlessCredentialResolverTests.
LinuxRejectsGroupOrOtherCredentialPermissions, is a pre-existing
Linux-only lane gate that throws PlatformNotSupportedException on this
Windows host — unrelated to this change).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 23:03:04 +02:00

316 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,
IPluginStorage? vtankProfiles = null)
{
_runtime = runtime ?? throw new ArgumentNullException(nameof(runtime));
Log = logger ?? throw new ArgumentNullException(nameof(logger));
Commands = commands ?? NoOpPluginCommandRegistry.Instance;
VtankProfiles = vtankProfiles ?? NoOpPluginStorage.Instance;
_eventSubscription = runtime.Subscribe(this);
}
public bool HasUi => false;
public IPluginLogger Log { get; }
public IPluginCommandRegistry Commands { get; }
public IPluginStorage VtankProfiles { 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();
}
}