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

@ -58,4 +58,25 @@ public sealed class ClientObjectTableUpdateTests
[Fact]
public void UpdateStackSize_unknownObject_returnsFalse()
=> Assert.False(new ClientObjectTable().UpdateStackSize(0xDEADu, 1, 1));
[Fact]
public void UpdateAppraisal_retainsPropertiesAndDefensiveSpellSnapshot()
{
var table = new ClientObjectTable();
table.AddOrUpdate(new ClientObject { ObjectId = 0x700u });
int updates = 0;
table.ObjectUpdated += _ => updates++;
var bundle = new PropertyBundle();
bundle.Ints[106] = 420;
uint[] spells = [1327u, 1132u];
Assert.True(table.UpdateAppraisal(0x700u, bundle, spells, 12.345d));
spells[0] = 0u;
ClientObject item = table.Get(0x700u)!;
Assert.Equal(420, item.Properties.Ints[106]);
Assert.Equal([1327u, 1132u], item.AppraisedSpellIds);
Assert.Equal(12345, item.LastAppraisalTimeMs);
Assert.Equal(1, updates);
}
}

View file

@ -0,0 +1,59 @@
using AcDream.Core.Plugins;
using AcDream.Plugin.Abstractions;
namespace AcDream.Core.Tests.Plugins;
public sealed class PluginCommandRegistryTests
{
[Fact]
public void RegisteredVerbHandlesBothPrefixesCaseInsensitively()
{
var registry = new PluginCommandRegistry();
var seen = new List<PluginCommand>();
using IDisposable lease = registry.Register("vt", seen.Add);
Assert.True(registry.TryHandle("/VT start"));
Assert.True(registry.TryHandle(" @vt opt get EnableCombat "));
Assert.Equal(2, seen.Count);
Assert.Equal("start", seen[0].Arguments);
Assert.Equal("opt get EnableCombat", seen[1].Arguments);
Assert.Equal("/VT start", seen[0].RawText);
}
[Fact]
public void ExactLeaseRemovalDoesNotConsumeUnknownServerCommand()
{
var registry = new PluginCommandRegistry();
IDisposable lease = registry.Register("vt", static _ => { });
lease.Dispose();
Assert.False(registry.TryHandle("/vt start"));
Assert.False(registry.TryHandle("hello"));
}
[Fact]
public void DuplicateVerbIsRejectedWithoutReplacingOwner()
{
var registry = new PluginCommandRegistry();
int calls = 0;
using IDisposable lease = registry.Register("vt", _ => calls++);
Assert.Throws<InvalidOperationException>(() =>
registry.Register("VT", static _ => { }));
Assert.True(registry.TryHandle("/vt"));
Assert.Equal(1, calls);
}
[Fact]
public void HandlerFailureIsContainedAndReported()
{
Exception? failure = null;
var registry = new PluginCommandRegistry((_, error) => failure = error);
using IDisposable lease = registry.Register(
"vt",
static _ => throw new InvalidOperationException("broken"));
Assert.True(registry.TryHandle("/vt start"));
Assert.Equal("broken", failure?.Message);
}
}

View file

@ -9,6 +9,11 @@ public class PluginLoaderTests
{
private static string FixturePluginPath()
{
const string fileName = "AcDream.Core.Tests.Fixtures.HelloPlugin.dll";
string colocated = Path.Combine(AppContext.BaseDirectory, fileName);
if (File.Exists(colocated))
return colocated;
// walk up from the test bin dir to the repo root, then into the fixture's build output
var baseDir = AppContext.BaseDirectory;
var configuration = new DirectoryInfo(baseDir).Parent!.Name; // Debug / Release
@ -16,7 +21,7 @@ public class PluginLoaderTests
return Path.Combine(
repoRoot,
"tests", "AcDream.Core.Tests.Fixtures.HelloPlugin", "bin", configuration, "net10.0",
"AcDream.Core.Tests.Fixtures.HelloPlugin.dll");
fileName);
}
private static string FindRepoRoot(string startDir)

View file

@ -0,0 +1,80 @@
using AcDream.Core.Plugins;
using AcDream.Plugin.Abstractions;
namespace AcDream.Core.Tests.Plugins;
public sealed class PluginLootClassifierRegistryTests
{
[Fact]
public void RegistrationClassifiesAndDisposalRemovesEntry()
{
var registry = new PluginLootClassifierRegistry();
var classifier = new Classifier();
IDisposable registration = registry.Register(
"test/rules",
"Test Rules",
classifier);
var context = new PluginLootClassificationContext(
default,
default,
[]);
Assert.Equal("test/rules", Assert.Single(registry.Available).Id);
Assert.True(registry.TryClassify(
"TEST/RULES",
context,
out PluginLootClassification classification));
Assert.True(classification.Matched);
Assert.Equal(PluginLootAction.Keep, classification.Action);
Assert.True(registry.TryNotifyLooted(
"test/rules",
new PluginLootedItem(default, PluginLootAction.User1)));
Assert.True(registry.TryNotifyItemRemoved("test/rules", 42u));
Assert.Equal(PluginLootAction.User1, Assert.Single(classifier.Looted).Action);
Assert.Equal(new[] { 42u }, classifier.Removed);
registration.Dispose();
Assert.Empty(registry.Available);
Assert.False(registry.TryClassify(
"test/rules",
context,
out _));
}
[Fact]
public void ClassifierFailureIsIsolatedAsUnavailableDecision()
{
var registry = new PluginLootClassifierRegistry();
using IDisposable registration = registry.Register(
"bad/rules",
"Bad Rules",
new ThrowingClassifier());
Assert.False(registry.TryClassify(
"bad/rules",
new PluginLootClassificationContext(default, default, []),
out _));
}
private sealed class Classifier : IPluginLootClassifier
{
public List<PluginLootedItem> Looted { get; } = [];
public List<uint> Removed { get; } = [];
public PluginLootClassification Classify(
in PluginLootClassificationContext context) =>
new(true, PluginLootAction.Keep, "External");
public void OnLooted(in PluginLootedItem item) => Looted.Add(item);
public void OnItemRemoved(uint objectId) => Removed.Add(objectId);
}
private sealed class ThrowingClassifier : IPluginLootClassifier
{
public PluginLootClassification Classify(
in PluginLootClassificationContext context) =>
throw new InvalidOperationException("classifier failed");
}
}

View file

@ -8,6 +8,56 @@ namespace AcDream.Core.Tests.Plugins;
public sealed class PluginSessionTests
{
[Fact]
public void ScopedHostPrefixesStorageWithAuthenticatedManifestId()
{
var storage = new MemoryStorage();
using var alpha = new ScopedPluginHost(
new StubHost(storage),
"acdream.alpha",
"Alpha");
using var beta = new ScopedPluginHost(
new StubHost(storage),
"acdream.beta",
"Beta");
alpha.Storage.WriteText("profile.json", "alpha");
beta.Storage.WriteText("profile.json", "beta");
alpha.Storage.WriteText("imports/route.nav", "nav");
Assert.Equal("alpha", storage.Text[Path.Combine(
"acdream.alpha", "profile.json")]);
Assert.Equal("beta", storage.Text[Path.Combine(
"acdream.beta", "profile.json")]);
Assert.Equal(["imports/route.nav"], alpha.Storage.List("imports"));
Assert.Empty(beta.Storage.List("imports"));
Assert.Throws<ArgumentException>(() =>
alpha.Storage.WriteText("../escape.json", "bad"));
}
[Fact]
public void ScopedHostNamespacesAndUnregistersLootClassifierOnDispose()
{
var global = new PluginLootClassifierRegistry();
var scope = new ScopedPluginHost(
new StubHost(lootClassifiers: global),
"acdream.looter",
"Looter");
scope.LootClassifiers.Register(
"main",
"My loot rules",
new KeepClassifier());
PluginLootClassifierInfo registered = Assert.Single(global.Available);
Assert.Equal("acdream.looter/main", registered.Id);
Assert.Equal("My loot rules", registered.DisplayName);
scope.Dispose();
Assert.Empty(global.Available);
}
[Fact]
public void AbsentAllowListLoadsEveryDiscoveredPlugin()
{
@ -282,6 +332,11 @@ public sealed class PluginSessionTests
private static string FixturePluginPath()
{
string fileName = "AcDream.Core.Tests.Fixtures.HelloPlugin.dll";
string colocated = Path.Combine(AppContext.BaseDirectory, fileName);
if (File.Exists(colocated))
return colocated;
string configuration = new DirectoryInfo(AppContext.BaseDirectory)
.Parent!.Name;
string root = FindRepoRoot(AppContext.BaseDirectory);
@ -292,7 +347,7 @@ public sealed class PluginSessionTests
"bin",
configuration,
"net10.0",
"AcDream.Core.Tests.Fixtures.HelloPlugin.dll");
fileName);
}
private static string FindRepoRoot(string start)
@ -307,7 +362,9 @@ public sealed class PluginSessionTests
?? throw new InvalidOperationException("Repository root not found.");
}
private sealed class StubHost : IPluginHost
private sealed class StubHost(
IPluginStorage? storage = null,
IPluginLootClassifierRegistry? lootClassifiers = null) : IPluginHost
{
public bool HasUi => false;
public IPluginLogger Log { get; } = new StubLogger();
@ -316,6 +373,34 @@ public sealed class PluginSessionTests
public ISelectionService Selection { get; } = new SelectionState();
public IUiRegistry Ui => NoOpUiRegistry.Instance;
public IAutomationSurface Automation => NoOpAutomationSurface.Instance;
public IPluginStorage Storage { get; } =
storage ?? NoOpPluginStorage.Instance;
public IPluginLootClassifierRegistry LootClassifiers { get; } =
lootClassifiers ?? NoOpPluginLootClassifierRegistry.Instance;
}
private sealed class KeepClassifier : IPluginLootClassifier
{
public PluginLootClassification Classify(
in PluginLootClassificationContext context) => new(
true,
PluginLootAction.Keep);
}
private sealed class MemoryStorage : IPluginStorage
{
public Dictionary<string, string> Text { get; } =
new(StringComparer.Ordinal);
public bool IsAvailable => true;
public string? ReadText(string key) =>
Text.TryGetValue(key, out string? value) ? value : null;
public IReadOnlyList<string> List(string prefix) => Text.Keys
.Where(key => key.StartsWith(prefix + Path.DirectorySeparatorChar,
StringComparison.Ordinal))
.OrderBy(static key => key, StringComparer.Ordinal)
.ToArray();
public void WriteText(string key, string content) => Text[key] = content;
public bool Delete(string key) => Text.Remove(key);
}
private sealed class StubLogger : IPluginLogger