feat(mosstank): add VTank-style automation PoC
This commit is contained in:
parent
f6fe0f2a4f
commit
4e6e9bc9d9
212 changed files with 49462 additions and 416 deletions
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue