feat(mosstank): add VTank-style automation PoC

This commit is contained in:
Erik 2026-08-27 18:57:21 +02:00
parent f6fe0f2a4f
commit 4e6e9bc9d9
212 changed files with 49462 additions and 416 deletions

View file

@ -13,14 +13,30 @@ internal sealed class ScopedPluginHost : IPluginHost, IDisposable
private readonly ScopedEvents _events;
private readonly ScopedSelectionService _selection;
private readonly ScopedUiRegistry _ui;
private readonly ScopedPluginStorage _storage;
private readonly ScopedPluginCommandRegistry _commands;
private readonly ScopedLootClassifierRegistry _lootClassifiers;
private bool _disposed;
internal ScopedPluginHost(IPluginHost inner)
internal ScopedPluginHost(
IPluginHost inner,
string pluginId,
string pluginDisplayName)
{
_inner = inner ?? throw new ArgumentNullException(nameof(inner));
ArgumentException.ThrowIfNullOrWhiteSpace(pluginId);
ArgumentException.ThrowIfNullOrWhiteSpace(pluginDisplayName);
_events = new ScopedEvents(inner.Events);
_selection = new ScopedSelectionService(inner.Selection);
_ui = new ScopedUiRegistry(inner.Ui);
_ui = new ScopedUiRegistry(
inner.Ui,
new PluginUiOwner(pluginId, pluginDisplayName));
_storage = new ScopedPluginStorage(inner.Storage, pluginId);
_commands = new ScopedPluginCommandRegistry(inner.Commands);
_lootClassifiers = new ScopedLootClassifierRegistry(
inner.LootClassifiers,
pluginId,
pluginDisplayName);
}
public bool HasUi => _inner.HasUi;
@ -29,6 +45,9 @@ internal sealed class ScopedPluginHost : IPluginHost, IDisposable
public IEvents Events => _events;
public ISelectionService Selection => _selection;
public IUiRegistry Ui => _ui;
public IPluginStorage Storage => _storage;
public IPluginCommandRegistry Commands => _commands;
public IPluginLootClassifierRegistry LootClassifiers => _lootClassifiers;
/// <summary>
/// Delegated rather than scoped, unlike <see cref="Events"/>,
@ -45,6 +64,48 @@ internal sealed class ScopedPluginHost : IPluginHost, IDisposable
/// </remarks>
public IAutomationSurface Automation => _inner.Automation;
private sealed class ScopedPluginStorage(
IPluginStorage inner,
string pluginId) : IPluginStorage
{
public bool IsAvailable => inner.IsAvailable;
public string? ReadText(string key) =>
inner.ReadText(ScopedKey(key));
public IReadOnlyList<string> List(string prefix)
{
string scopedPrefix = ScopedKey(prefix);
string ownerPrefix = pluginId + Path.DirectorySeparatorChar;
return inner.List(scopedPrefix)
.Select(key => key.Replace('/', Path.DirectorySeparatorChar))
.Where(key => key.StartsWith(
ownerPrefix,
OperatingSystem.IsWindows()
? StringComparison.OrdinalIgnoreCase
: StringComparison.Ordinal))
.Select(key => key[ownerPrefix.Length..]
.Replace(Path.DirectorySeparatorChar, '/'))
.ToArray();
}
public void WriteText(string key, string content) =>
inner.WriteText(ScopedKey(key), content);
public bool Delete(string key) => inner.Delete(ScopedKey(key));
private static string ValidateKey(string key)
{
ArgumentException.ThrowIfNullOrWhiteSpace(key);
if (Path.IsPathRooted(key)
|| key.Contains("..", StringComparison.Ordinal)
|| key.Contains('\\'))
{
throw new ArgumentException("Invalid plugin storage key.", nameof(key));
}
return key.Replace('/', Path.DirectorySeparatorChar);
}
private string ScopedKey(string key) =>
Path.Combine(pluginId, ValidateKey(key));
}
public void Dispose()
{
if (_disposed)
@ -53,6 +114,127 @@ internal sealed class ScopedPluginHost : IPluginHost, IDisposable
_events.Dispose();
_selection.Dispose();
_ui.Dispose();
_commands.Dispose();
_lootClassifiers.Dispose();
}
private sealed class ScopedLootClassifierRegistry(
IPluginLootClassifierRegistry inner,
string pluginId,
string pluginDisplayName)
: IPluginLootClassifierRegistry,
IDisposable
{
private readonly object _gate = new();
private readonly List<IDisposable> _registrations = [];
private bool _disposed;
public IReadOnlyList<PluginLootClassifierInfo> Available =>
inner.Available;
public IDisposable Register(
string classifierId,
string displayName,
IPluginLootClassifier classifier)
{
ObjectDisposedException.ThrowIf(_disposed, this);
ArgumentException.ThrowIfNullOrWhiteSpace(classifierId);
string local = classifierId.Trim();
if (local.Contains('/') || local.Contains('\\'))
{
throw new ArgumentException(
"A classifier id cannot contain a path separator.",
nameof(classifierId));
}
string effectiveName = string.IsNullOrWhiteSpace(displayName)
? pluginDisplayName
: displayName.Trim();
IDisposable registration = inner.Register(
$"{pluginId}/{local}",
effectiveName,
classifier);
lock (_gate)
{
if (!_disposed)
{
_registrations.Add(registration);
return registration;
}
}
registration.Dispose();
throw new ObjectDisposedException(nameof(ScopedLootClassifierRegistry));
}
public bool TryClassify(
string classifierId,
in PluginLootClassificationContext context,
out PluginLootClassification classification) =>
inner.TryClassify(classifierId, context, out classification);
public bool TryNotifyLooted(
string classifierId,
in PluginLootedItem item) =>
inner.TryNotifyLooted(classifierId, item);
public bool TryNotifyItemRemoved(
string classifierId,
uint objectId) =>
inner.TryNotifyItemRemoved(classifierId, objectId);
public void Dispose()
{
IDisposable[] registrations;
lock (_gate)
{
if (_disposed)
return;
_disposed = true;
registrations = _registrations.ToArray();
_registrations.Clear();
}
for (int index = registrations.Length - 1; index >= 0; index--)
registrations[index].Dispose();
}
}
private sealed class ScopedPluginCommandRegistry(IPluginCommandRegistry inner)
: IPluginCommandRegistry,
IDisposable
{
private readonly object _gate = new();
private readonly List<IDisposable> _registrations = [];
private bool _disposed;
public IDisposable Register(string verb, Action<PluginCommand> handler)
{
ObjectDisposedException.ThrowIf(_disposed, this);
IDisposable registration = inner.Register(verb, handler);
lock (_gate)
{
if (!_disposed)
{
_registrations.Add(registration);
return registration;
}
}
registration.Dispose();
throw new ObjectDisposedException(nameof(ScopedPluginCommandRegistry));
}
public void Dispose()
{
IDisposable[] registrations;
lock (_gate)
{
if (_disposed)
return;
_disposed = true;
registrations = _registrations.ToArray();
_registrations.Clear();
}
for (int index = registrations.Length - 1; index >= 0; index--)
registrations[index].Dispose();
}
}
private sealed class ScopedSelectionService(ISelectionService inner)
@ -297,22 +479,92 @@ internal sealed class ScopedPluginHost : IPluginHost, IDisposable
private sealed class ScopedUiRegistry : IUiRegistry, IDisposable
{
private readonly IScopedUiRegistry _inner;
private readonly PluginUiOwner _owner;
private readonly object _gate = new();
private readonly List<IDisposable> _registrations = [];
private bool _disposed;
internal ScopedUiRegistry(IUiRegistry inner)
internal ScopedUiRegistry(IUiRegistry inner, PluginUiOwner owner)
{
_inner = inner as IScopedUiRegistry
?? throw new InvalidOperationException(
"Plugin hosts must expose an IScopedUiRegistry so UI registrations can be rolled back.");
_owner = owner;
}
public void AddMarkupPanel(string markupPath, object binding)
{
IDisposable registration = _inner.RegisterMarkupPanel(
AddRegistration(_inner.RegisterPanel(
_owner,
new PluginPanelDescriptor(
Path.GetFileNameWithoutExtension(markupPath),
_owner.DisplayName),
markupPath,
binding);
binding));
}
public void AddPanel(
PluginPanelDescriptor descriptor,
string markupPath,
object binding)
{
ArgumentNullException.ThrowIfNull(descriptor);
AddRegistration(_inner.RegisterPanel(
_owner,
descriptor,
markupPath,
binding));
}
public IDisposable RegisterPanel(
PluginPanelDescriptor descriptor,
string markupPath,
object binding)
{
ArgumentNullException.ThrowIfNull(descriptor);
return TrackRegistration(_inner.RegisterPanel(
_owner,
descriptor,
markupPath,
binding));
}
public IDisposable RegisterPanelContent(
PluginPanelDescriptor descriptor,
string markupContent,
object binding)
{
ArgumentNullException.ThrowIfNull(descriptor);
return TrackRegistration(_inner.RegisterPanelContent(
_owner,
descriptor,
markupContent,
binding));
}
public bool ViewExists(string viewName) =>
_inner.ViewExists(_owner, viewName);
public bool IsViewVisible(string viewName) =>
_inner.IsViewVisible(_owner, viewName);
public bool ControlExists(string viewName, string controlName) =>
_inner.ControlExists(_owner, viewName, controlName);
public bool SetControlLabel(
string viewName,
string controlName,
string label) =>
_inner.SetControlLabel(_owner, viewName, controlName, label);
public bool SetControlVisible(
string viewName,
string controlName,
bool visible) =>
_inner.SetControlVisible(_owner, viewName, controlName, visible);
private void AddRegistration(IDisposable registration)
{
lock (_gate)
{
if (!_disposed)
@ -326,6 +578,31 @@ internal sealed class ScopedPluginHost : IPluginHost, IDisposable
throw new ObjectDisposedException(nameof(ScopedUiRegistry));
}
private IDisposable TrackRegistration(IDisposable registration)
{
lock (_gate)
{
if (!_disposed)
{
_registrations.Add(registration);
return new IndividualRegistration(this, registration);
}
}
registration.Dispose();
throw new ObjectDisposedException(nameof(ScopedUiRegistry));
}
private void RemoveRegistration(IDisposable registration)
{
lock (_gate)
{
if (!_registrations.Remove(registration))
return;
}
registration.Dispose();
}
public void Dispose()
{
IDisposable[] registrations;
@ -344,5 +621,15 @@ internal sealed class ScopedPluginHost : IPluginHost, IDisposable
catch { }
}
}
private sealed class IndividualRegistration(
ScopedUiRegistry owner,
IDisposable registration) : IDisposable
{
private ScopedUiRegistry? _owner = owner;
public void Dispose() => Interlocked.Exchange(ref _owner, null)?
.RemoveRegistration(registration);
}
}
}