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

File diff suppressed because it is too large Load diff

View file

@ -10,7 +10,10 @@ public sealed class AppPluginHost : IPluginHost
IEvents events,
ISelectionService selection,
IUiRegistry ui,
IAutomationSurface automation)
IAutomationSurface automation,
IPluginStorage? storage = null,
IPluginCommandRegistry? commands = null,
IPluginLootClassifierRegistry? lootClassifiers = null)
{
Log = log;
State = state;
@ -18,6 +21,10 @@ public sealed class AppPluginHost : IPluginHost
Selection = selection;
Ui = ui;
Automation = automation;
Storage = storage ?? NoOpPluginStorage.Instance;
Commands = commands ?? NoOpPluginCommandRegistry.Instance;
LootClassifiers = lootClassifiers
?? NoOpPluginLootClassifierRegistry.Instance;
}
public bool HasUi => true;
@ -27,4 +34,7 @@ public sealed class AppPluginHost : IPluginHost
public ISelectionService Selection { get; }
public IUiRegistry Ui { get; }
public IAutomationSurface Automation { get; }
public IPluginStorage Storage { get; }
public IPluginCommandRegistry Commands { get; }
public IPluginLootClassifierRegistry LootClassifiers { get; }
}

View file

@ -11,18 +11,36 @@ namespace AcDream.App.Plugins;
/// </summary>
public sealed class BufferedUiRegistry : IScopedUiRegistry
{
public readonly record struct Pending(string MarkupPath, object Binding)
public readonly record struct Pending(
PluginUiOwner Owner,
PluginPanelDescriptor Descriptor,
string MarkupPath,
object Binding)
{
internal long RegistrationId { get; init; }
internal string? MarkupContent { get; init; }
/// <summary>Stable, manifest-scoped retained-window persistence key.</summary>
public string WindowName =>
$"plugin:{Owner.Id}:{Descriptor.WindowId}";
}
private sealed class Registration(string markupPath, object binding)
private sealed class Registration(
PluginUiOwner owner,
PluginPanelDescriptor descriptor,
string markupPath,
object binding,
string? markupContent = null)
{
internal PluginUiOwner Owner { get; } = owner;
internal PluginPanelDescriptor Descriptor { get; } = descriptor;
internal string MarkupPath { get; } = markupPath;
internal object Binding { get; } = binding;
internal string? MarkupContent { get; } = markupContent;
internal bool Drained { get; set; }
internal UiRoot? Root { get; set; }
internal UiElement? Element { get; set; }
internal Action? WindowCleanup { get; set; }
}
private readonly object _gate = new();
@ -32,15 +50,114 @@ public sealed class BufferedUiRegistry : IScopedUiRegistry
public void AddMarkupPanel(string markupPath, object binding)
=> _ = RegisterMarkupPanel(markupPath, binding);
public void AddPanel(
PluginPanelDescriptor descriptor,
string markupPath,
object binding)
=> _ = RegisterPanel(
new PluginUiOwner("unscoped", descriptor.Title),
descriptor,
markupPath,
binding);
public IDisposable RegisterPanel(
PluginPanelDescriptor descriptor,
string markupPath,
object binding) => RegisterPanel(
new PluginUiOwner("unscoped", descriptor.Title),
descriptor,
markupPath,
binding);
public IDisposable RegisterPanelContent(
PluginPanelDescriptor descriptor,
string markupContent,
object binding) => RegisterPanelContent(
new PluginUiOwner("unscoped", descriptor.Title),
descriptor,
markupContent,
binding);
public bool ViewExists(string viewName) =>
ViewExists(new PluginUiOwner("unscoped", "Plugin"), viewName);
public bool IsViewVisible(string viewName) =>
IsViewVisible(new PluginUiOwner("unscoped", "Plugin"), viewName);
public bool ControlExists(string viewName, string controlName) =>
ControlExists(
new PluginUiOwner("unscoped", "Plugin"), viewName, controlName);
public bool SetControlLabel(
string viewName,
string controlName,
string label) => SetControlLabel(
new PluginUiOwner("unscoped", "Plugin"), viewName, controlName, label);
public bool SetControlVisible(
string viewName,
string controlName,
bool visible) => SetControlVisible(
new PluginUiOwner("unscoped", "Plugin"), viewName, controlName, visible);
public IDisposable RegisterMarkupPanel(string markupPath, object binding)
=> RegisterPanel(
new PluginUiOwner("legacy", "Plugin"),
new PluginPanelDescriptor(
Path.GetFileNameWithoutExtension(markupPath),
Path.GetFileNameWithoutExtension(markupPath)),
markupPath,
binding);
public IDisposable RegisterPanel(
PluginUiOwner owner,
PluginPanelDescriptor descriptor,
string markupPath,
object binding)
{
ArgumentException.ThrowIfNullOrWhiteSpace(owner.Id);
ArgumentException.ThrowIfNullOrWhiteSpace(owner.DisplayName);
ArgumentNullException.ThrowIfNull(descriptor);
ArgumentException.ThrowIfNullOrWhiteSpace(descriptor.WindowId);
ArgumentException.ThrowIfNullOrWhiteSpace(descriptor.Title);
ArgumentException.ThrowIfNullOrWhiteSpace(markupPath);
ArgumentNullException.ThrowIfNull(binding);
long id;
lock (_gate)
{
id = checked(++_nextRegistrationId);
_registrations.Add(id, new Registration(markupPath, binding));
_registrations.Add(
id,
new Registration(owner, descriptor, markupPath, binding));
}
return new RegistrationToken(this, id);
}
public IDisposable RegisterPanelContent(
PluginUiOwner owner,
PluginPanelDescriptor descriptor,
string markupContent,
object binding)
{
ArgumentException.ThrowIfNullOrWhiteSpace(owner.Id);
ArgumentException.ThrowIfNullOrWhiteSpace(owner.DisplayName);
ArgumentNullException.ThrowIfNull(descriptor);
ArgumentException.ThrowIfNullOrWhiteSpace(descriptor.WindowId);
ArgumentException.ThrowIfNullOrWhiteSpace(descriptor.Title);
ArgumentException.ThrowIfNullOrWhiteSpace(markupContent);
ArgumentNullException.ThrowIfNull(binding);
long id;
lock (_gate)
{
id = checked(++_nextRegistrationId);
_registrations.Add(
id,
new Registration(
owner,
descriptor,
$"<inline:{descriptor.WindowId}>",
binding,
markupContent));
}
return new RegistrationToken(this, id);
}
@ -57,10 +174,13 @@ public sealed class BufferedUiRegistry : IScopedUiRegistry
continue;
registration.Drained = true;
pending.Add(new Pending(
registration.Owner,
registration.Descriptor,
registration.MarkupPath,
registration.Binding)
{
RegistrationId = id,
MarkupContent = registration.MarkupContent,
});
}
return pending;
@ -88,6 +208,27 @@ public sealed class BufferedUiRegistry : IScopedUiRegistry
root.RemoveChild(element);
}
/// <summary>
/// Publishes the window-manager half of a mounted registration. Disposal
/// may race between retained-tree mount and window registration, so a late
/// publication cleans itself up immediately when ownership is already gone.
/// </summary>
internal void CompleteWindowMount(Pending pending, Action cleanup)
{
ArgumentNullException.ThrowIfNull(cleanup);
bool stillRegistered;
lock (_gate)
{
stillRegistered = _registrations.TryGetValue(
pending.RegistrationId,
out Registration? registration);
if (stillRegistered)
registration!.WindowCleanup = cleanup;
}
if (!stillRegistered)
cleanup();
}
internal void FailMount(Pending pending) => Remove(pending.RegistrationId);
internal int RegistrationCount
@ -99,18 +240,114 @@ public sealed class BufferedUiRegistry : IScopedUiRegistry
}
}
public bool ViewExists(PluginUiOwner owner, string viewName)
{
lock (_gate)
return FindRegistrationLocked(owner, viewName) is not null;
}
public bool IsViewVisible(PluginUiOwner owner, string viewName)
{
UiElement? view;
lock (_gate)
view = FindRegistrationLocked(owner, viewName)?.Element;
return view?.Visible == true;
}
public bool ControlExists(
PluginUiOwner owner,
string viewName,
string controlName) =>
FindControl(owner, viewName, controlName) is not null;
public bool SetControlLabel(
PluginUiOwner owner,
string viewName,
string controlName,
string label)
{
UiElement? control = FindControl(owner, viewName, controlName);
switch (control)
{
case UiSimpleButton button:
button.TextSource = null;
button.Text = label;
return true;
case UiMarkupToggle toggle:
toggle.TextSource = null;
toggle.Text = label;
return true;
case UiLabel text:
text.TextSource = null;
text.Text = label;
return true;
default:
return false;
}
}
public bool SetControlVisible(
PluginUiOwner owner,
string viewName,
string controlName,
bool visible)
{
UiElement? control = FindControl(owner, viewName, controlName);
if (control is null)
return false;
control.VisibleSource = null;
control.Visible = visible;
return true;
}
private UiElement? FindControl(
PluginUiOwner owner,
string viewName,
string controlName)
{
UiElement? view;
lock (_gate)
view = FindRegistrationLocked(owner, viewName)?.Element;
return view is null ? null : FindByName(view, controlName);
}
private Registration? FindRegistrationLocked(
PluginUiOwner owner,
string viewName) => _registrations.Values.FirstOrDefault(registration =>
registration.Owner == owner
&& (registration.Descriptor.WindowId.Equals(
viewName, StringComparison.Ordinal)
|| registration.Descriptor.Title.Equals(
viewName, StringComparison.Ordinal)));
private static UiElement? FindByName(UiElement root, string name)
{
if (root.Name?.Equals(name, StringComparison.Ordinal) == true)
return root;
foreach (UiElement child in root.Children)
{
UiElement? found = FindByName(child, name);
if (found is not null)
return found;
}
return null;
}
private void Remove(long id)
{
UiRoot? root;
UiElement? element;
Action? windowCleanup;
lock (_gate)
{
if (!_registrations.Remove(id, out Registration? registration))
return;
root = registration.Root;
element = registration.Element;
windowCleanup = registration.WindowCleanup;
}
windowCleanup?.Invoke();
if (root is not null && element is not null)
root.RemoveChild(element);
}

View file

@ -0,0 +1,86 @@
using System.Text;
using AcDream.Plugin.Abstractions;
namespace AcDream.App.Plugins;
/// <summary>Crash-safe filesystem implementation behind scoped plugin keys.</summary>
internal sealed class FilePluginStorage : IPluginStorage
{
private readonly string _root;
internal FilePluginStorage(string root)
{
ArgumentException.ThrowIfNullOrWhiteSpace(root);
_root = Path.GetFullPath(root);
}
public bool IsAvailable => true;
public string? ReadText(string key)
{
string path = Resolve(key);
return File.Exists(path)
? File.ReadAllText(path, Encoding.UTF8)
: null;
}
public IReadOnlyList<string> List(string prefix)
{
string directory = Resolve(prefix);
if (!Directory.Exists(directory))
return Array.Empty<string>();
return Directory.EnumerateFiles(directory, "*", SearchOption.AllDirectories)
.Select(path => Path.GetRelativePath(_root, path)
.Replace(Path.DirectorySeparatorChar, '/'))
.OrderBy(static key => key, StringComparer.OrdinalIgnoreCase)
.ToArray();
}
public void WriteText(string key, string content)
{
ArgumentNullException.ThrowIfNull(content);
string path = Resolve(key);
string directory = Path.GetDirectoryName(path)!;
Directory.CreateDirectory(directory);
string temporary = Path.Combine(
directory,
$".{Path.GetFileName(path)}.{Guid.NewGuid():N}.tmp");
try
{
File.WriteAllText(temporary, content, new UTF8Encoding(false));
File.Move(temporary, path, overwrite: true);
}
finally
{
if (File.Exists(temporary))
File.Delete(temporary);
}
}
public bool Delete(string key)
{
string path = Resolve(key);
if (!File.Exists(path))
return false;
File.Delete(path);
return true;
}
private string Resolve(string key)
{
ArgumentException.ThrowIfNullOrWhiteSpace(key);
if (Path.IsPathRooted(key))
throw new ArgumentException("Plugin storage keys must be relative.", nameof(key));
string path = Path.GetFullPath(Path.Combine(_root, key));
string relative = Path.GetRelativePath(_root, path);
if (Path.IsPathRooted(relative)
|| relative.Equals("..", StringComparison.Ordinal)
|| relative.StartsWith(
".." + Path.DirectorySeparatorChar,
StringComparison.Ordinal))
{
throw new ArgumentException("Plugin storage key escapes its root.", nameof(key));
}
return path;
}
}

View file

@ -0,0 +1,225 @@
using System.Text.Json;
using AcDream.Plugin.Abstractions;
namespace AcDream.App.Plugins;
/// <summary>
/// Small cross-process peer roster for plugins. UtilityBelt used a local TCP
/// relay; acdream uses bounded heartbeat documents in the user's local app
/// data, which provides the same machine-local discovery without a privileged
/// daemon or a fixed port. The files carry data only—never commands.
/// </summary>
internal sealed class LocalPluginPeerRegistry : IDisposable
{
internal static readonly TimeSpan StaleAfter = TimeSpan.FromSeconds(15);
private const long MaximumDocumentBytes = 64 * 1024;
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true,
};
private readonly string _directory;
private readonly string _path;
private readonly TimeProvider _time;
private readonly Guid _instanceId;
private bool _disposed;
public LocalPluginPeerRegistry(
string directory,
TimeProvider? timeProvider = null,
Guid? instanceId = null)
{
ArgumentException.ThrowIfNullOrWhiteSpace(directory);
_directory = Path.GetFullPath(directory);
_time = timeProvider ?? TimeProvider.System;
_instanceId = instanceId ?? Guid.NewGuid();
_path = Path.Combine(_directory, $"peer-{_instanceId:N}.json");
ClientId = BitConverter.ToUInt32(_instanceId.ToByteArray(), 0);
if (ClientId == 0u)
ClientId = 1u;
}
public uint ClientId { get; private set; }
public void Publish(in PluginNetworkClient client)
{
ObjectDisposedException.ThrowIf(_disposed, this);
Directory.CreateDirectory(_directory);
var document = PeerDocument.From(
client with { ClientId = ClientId },
_instanceId,
_time.GetUtcNow().ToUnixTimeMilliseconds());
string temporary = _path + "." + Guid.NewGuid().ToString("N") + ".tmp";
try
{
File.WriteAllText(temporary, JsonSerializer.Serialize(document, JsonOptions));
File.Move(temporary, _path, overwrite: true);
}
finally
{
if (File.Exists(temporary))
File.Delete(temporary);
}
}
public IReadOnlyList<PluginNetworkClient> CaptureRemoteClients()
{
ObjectDisposedException.ThrowIf(_disposed, this);
if (!Directory.Exists(_directory))
return Array.Empty<PluginNetworkClient>();
long newestAllowed = _time.GetUtcNow().Subtract(StaleAfter)
.ToUnixTimeMilliseconds();
var result = new List<PluginNetworkClient>();
foreach (string file in Directory.EnumerateFiles(
_directory,
"peer-*.json",
SearchOption.TopDirectoryOnly))
{
if (file.Equals(_path, StringComparison.OrdinalIgnoreCase))
continue;
try
{
var info = new FileInfo(file);
if (info.Length is <= 0 or > MaximumDocumentBytes)
continue;
PeerDocument? document = JsonSerializer.Deserialize<PeerDocument>(
File.ReadAllText(file),
JsonOptions);
if (document is null
|| document.InstanceId == _instanceId
|| document.UpdatedUnixMs < newestAllowed
|| document.ClientId == 0u
|| document.PlayerId == 0u
|| string.IsNullOrWhiteSpace(document.Name)
|| document.Name.Length > 128
|| document.WorldName is null
|| document.WorldName.Length > 128
|| document.Tags is null
|| document.Tags.Length > 128
|| !double.IsFinite(document.EastWest)
|| !double.IsFinite(document.NorthSouth)
|| !double.IsFinite(document.Elevation)
|| !float.IsFinite(document.Heading))
{
continue;
}
result.Add(document.ToClient());
}
catch (IOException)
{
// A peer can atomically replace or remove its own heartbeat
// between enumeration and read. It will reappear next scan.
}
catch (UnauthorizedAccessException)
{
}
catch (JsonException)
{
}
}
return result
.OrderBy(static client => client.Name, StringComparer.OrdinalIgnoreCase)
.ThenBy(static client => client.ClientId)
.ToArray();
}
public void Withdraw()
{
if (_disposed)
return;
try
{
if (File.Exists(_path))
File.Delete(_path);
}
catch (IOException)
{
}
catch (UnauthorizedAccessException)
{
}
}
public void Dispose()
{
if (_disposed)
return;
Withdraw();
_disposed = true;
}
private sealed class PeerDocument
{
public Guid InstanceId { get; set; }
public long UpdatedUnixMs { get; set; }
public uint ClientId { get; set; }
public uint PlayerId { get; set; }
public string Name { get; set; } = string.Empty;
public string WorldName { get; set; } = string.Empty;
public string[] Tags { get; set; } = [];
public uint CellId { get; set; }
public double EastWest { get; set; }
public double NorthSouth { get; set; }
public double Elevation { get; set; }
public bool IsOutdoor { get; set; }
public float Heading { get; set; }
public uint CurrentHealth { get; set; }
public uint CurrentMana { get; set; }
public uint CurrentStamina { get; set; }
public uint MaxHealth { get; set; }
public uint MaxMana { get; set; }
public uint MaxStamina { get; set; }
public static PeerDocument From(
in PluginNetworkClient client,
Guid instanceId,
long updatedUnixMs) => new()
{
InstanceId = instanceId,
UpdatedUnixMs = updatedUnixMs,
ClientId = client.ClientId,
PlayerId = client.PlayerId,
Name = client.Name,
WorldName = client.WorldName,
Tags = client.Tags
.Where(static tag => !string.IsNullOrWhiteSpace(tag))
.Select(static tag => tag.Trim())
.Distinct(StringComparer.OrdinalIgnoreCase)
.Take(128)
.ToArray(),
CellId = client.Position.CellId,
EastWest = client.Position.EastWest,
NorthSouth = client.Position.NorthSouth,
Elevation = client.Position.Elevation,
IsOutdoor = client.Position.IsOutdoor,
Heading = client.Heading,
CurrentHealth = client.CurrentHealth,
CurrentMana = client.CurrentMana,
CurrentStamina = client.CurrentStamina,
MaxHealth = client.MaxHealth,
MaxMana = client.MaxMana,
MaxStamina = client.MaxStamina,
};
public PluginNetworkClient ToClient() => new(
ClientId,
PlayerId,
Name,
WorldName,
new PluginNavigationPosition(
CellId,
EastWest,
NorthSouth,
Elevation,
Heading,
IsOutdoor),
Tags,
CurrentHealth,
CurrentMana,
CurrentStamina,
MaxHealth,
MaxMana,
MaxStamina,
Heading);
}
}