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

@ -0,0 +1,277 @@
using AcDream.App.Plugins;
using AcDream.Core.Chat;
using AcDream.Core.Items;
using AcDream.Core.Physics;
using AcDream.Core.Selection;
using AcDream.Core.Spells;
using AcDream.Plugin.Abstractions;
using AcDream.Runtime.Gameplay;
using System.Numerics;
namespace AcDream.App.Tests.Plugins;
public sealed class AppAutomationSurfaceTests
{
[Fact]
public void ProjectileDebugSamplesAreDetachedValidatedAndClearedOnUnbind()
{
using var surface = new AppAutomationSurface();
PluginProjectileDebugSample[] source =
[
new(new Vector3(1f, 2f, 3f), true, 0.4f),
new(new Vector3(float.NaN, 0f, 0f), false, 0.4f),
];
surface.Projectiles.ShowDebugSamples(source);
source[0] = default;
PluginProjectileDebugSample sample = Assert.Single(
surface.CaptureProjectileDebugSamples());
Assert.Equal(new Vector3(1f, 2f, 3f), sample.WorldPosition);
Assert.True(sample.IsClear);
surface.Unbind();
Assert.Empty(surface.CaptureProjectileDebugSamples());
}
[Fact]
public void SelectionAutomationUsesTheBoundCanonicalActionRoute()
{
using var surface = new AppAutomationSurface();
var actions = new List<PluginSelectionAction>();
surface.BindSelectionActions(action =>
{
actions.Add(action);
return true;
});
Assert.True(surface.Selection.Execute(
PluginSelectionAction.PreviousSelection));
Assert.True(surface.Selection.Execute(
PluginSelectionAction.NextPlayer));
Assert.Equal(
[
PluginSelectionAction.PreviousSelection,
PluginSelectionAction.NextPlayer,
],
actions);
}
[Theory]
[InlineData((uint)ItemType.MeleeWeapon, 0u, PluginObjectClass.MeleeWeapon)]
[InlineData((uint)ItemType.Armor, 0u, PluginObjectClass.Armor)]
[InlineData((uint)ItemType.Creature, 0x10u, PluginObjectClass.Monster)]
[InlineData((uint)ItemType.Creature, 0u, PluginObjectClass.Npc)]
[InlineData((uint)ItemType.Creature, 0x04000010u, PluginObjectClass.CombatPet)]
[InlineData((uint)ItemType.Creature, 0x8u, PluginObjectClass.Player)]
[InlineData((uint)ItemType.Misc, 0x200u, PluginObjectClass.Vendor)]
[InlineData((uint)ItemType.Misc, 0x1000u, PluginObjectClass.Door)]
public void ObjectClassProjectionMatchesVirindiPriority(
uint itemType,
uint publicFlags,
PluginObjectClass expected)
{
var item = new ClientObject
{
ObjectId = 1u,
Type = (ItemType)itemType,
PublicWeenieBitfield = publicFlags,
};
Assert.Equal(expected, AppAutomationSurface.ClassifyObject(item));
}
[Fact]
public void NavigationProjectionUsesVtankMapCoordinatesAndCompassHeading()
{
PluginNavigationPosition center =
AppAutomationSurface.ProjectNavigationPosition(new Position(
0x7F7F0001u,
new Vector3(84f, 84f, 240f),
Quaternion.Identity));
Assert.Equal(0d, center.EastWest, 8);
Assert.Equal(0d, center.NorthSouth, 8);
Assert.Equal(1d, center.Elevation, 8);
Assert.Equal(0f, center.HeadingDegrees, 4);
Assert.True(center.IsOutdoor);
PluginNavigationPosition nextBlock =
AppAutomationSurface.ProjectNavigationPosition(new Position(
0x80800041u,
new Vector3(84f, 84f, 0f),
Quaternion.Identity));
Assert.Equal(0.8d, nextBlock.EastWest, 8);
Assert.Equal(0.8d, nextBlock.NorthSouth, 8);
Assert.False(nextBlock.IsOutdoor);
}
[Fact]
public void ChatCapture_isOrderedCursorBasedAndDetachesAcrossSessions()
{
using var first = GameRuntimeTestFactory.Create();
using var second = GameRuntimeTestFactory.Create();
using var surface = new AppAutomationSurface();
surface.Bind(
first,
first.CharacterOwner,
first.ActionOwner.SpellCast);
first.CommunicationOwner.AddText(
"You cast Imperil Other VII on Olthoi.",
RetailLogTextType.Magic);
PluginChatMessage one = Assert.Single(surface.CaptureMessages(0));
Assert.Equal("You cast Imperil Other VII on Olthoi.", one.Text);
Assert.Empty(surface.CaptureMessages(one.Sequence));
surface.Bind(
second,
second.CharacterOwner,
second.ActionOwner.SpellCast);
first.CommunicationOwner.AddText(
"stale first-session line",
RetailLogTextType.Magic);
second.CommunicationOwner.AddText(
"You cast Fester Other VII on Olthoi.",
RetailLogTextType.Magic);
PluginChatMessage two = Assert.Single(
surface.CaptureMessages(one.Sequence));
Assert.True(two.Sequence > one.Sequence);
Assert.Equal("You cast Fester Other VII on Olthoi.", two.Text);
Assert.Equal(1, second.CommunicationOwner.SubscriberCount);
surface.Dispose();
Assert.Equal(0, second.CommunicationOwner.SubscriberCount);
}
[Fact]
public void InventoryCompletionProjectsTheCanonicalRequestReceipt()
{
using var runtime = GameRuntimeTestFactory.Create();
using var surface = new AppAutomationSurface();
surface.Bind(runtime, runtime.CharacterOwner, runtime.ActionOwner.SpellCast);
ClientObjectTable objects = runtime.InventoryOwner.Objects;
const uint itemId = 0x50000123u;
objects.AddOrUpdate(new ClientObject
{
ObjectId = itemId,
Name = "Stack",
StackSize = 10,
StackSizeMax = 100,
});
Assert.True(runtime.InventoryOwner.Transactions.TryDispatch(
InventoryRequestKind.Merge,
itemId,
static () => true));
Assert.True(objects.UpdateStackSize(itemId, 9, 0));
PluginInventoryCompletion completion =
surface.Items.LastInventoryCompletion;
Assert.True(completion.Revision > 0);
Assert.Equal(PluginInventoryCommandKind.Merge, completion.Kind);
Assert.Equal(itemId, completion.SourceObjectId);
Assert.True(completion.IsSuccess);
}
[Fact]
public void RecoveryClearsExactlyOneCanonicalBusyReference()
{
using var runtime = GameRuntimeTestFactory.Create();
using var surface = new AppAutomationSurface();
surface.Bind(runtime, runtime.CharacterOwner, runtime.ActionOwner.SpellCast);
runtime.InventoryOwner.Transactions.IncrementBusyCount();
runtime.InventoryOwner.Transactions.IncrementBusyCount();
PluginRecoveryResult first = surface.Recovery.ClearOneBusyReference();
PluginRecoveryResult second = surface.Recovery.ClearOneBusyReference();
PluginRecoveryResult alreadyClear =
surface.Recovery.ClearOneBusyReference();
Assert.True(first.Accepted);
Assert.Equal((2, 1), (first.PreviousCount, first.CurrentCount));
Assert.Equal((1, 0), (second.PreviousCount, second.CurrentCount));
Assert.Equal((0, 0),
(alreadyClear.PreviousCount, alreadyClear.CurrentCount));
Assert.Equal(0, runtime.InventoryOwner.Transactions.BusyCount);
}
[Fact]
public void EnchantmentLedgerSharesReportedAndConfirmedLocalDurationCasts()
{
var operations = new SpellOperations();
using var runtime = GameRuntimeTestFactory.Create(spellCast: operations);
runtime.CharacterOwner.InstallSpellMetadata(SpellTable.Create([DurationSpell()]));
runtime.CharacterOwner.Spellbook.OnSpellLearned(42u);
using var surface = new AppAutomationSurface();
surface.Bind(runtime, runtime.CharacterOwner, runtime.ActionOwner.SpellCast);
Assert.True(surface.Enchantments.ReportCast(100u, 42u, 30d));
PluginTrackedEnchantment reported = Assert.Single(
surface.Enchantments.Capture(100u));
Assert.Equal(7u, reported.Family);
Assert.Equal(350, reported.Quality);
Assert.InRange(reported.SecondsRemaining, 29d, 30d);
runtime.ActionOwner.Selection.Select(
200u,
SelectionChangeSource.Plugin);
Assert.Equal(
CastRequestResult.Sent,
runtime.ActionOwner.SpellCast.Cast(42u));
Assert.True(runtime.ActionOwner.SpellCast.CompleteUse(0u));
Assert.True(surface.Magic.LastCompletion.IsSuccess);
PluginTrackedEnchantment local = Assert.Single(
surface.Enchantments.Capture(200u));
Assert.Equal(42u, local.SpellId);
Assert.InRange(local.SecondsRemaining, 59d, 60d);
surface.Unbind();
Assert.Empty(surface.Enchantments.Capture(100u));
Assert.Empty(surface.Enchantments.Capture(200u));
}
private static SpellMetadata DurationSpell() => new(
42u,
"Fire Vulnerability Other VII",
"Life Magic",
7u,
0u,
string.Empty,
60f,
10,
true,
false,
string.Empty,
0,
350,
0u,
7,
false,
true,
false,
0f,
0u,
0u,
1u,
0);
private sealed class SpellOperations : IRuntimeSpellCastOperations
{
public uint LocalPlayerId => 1u;
public bool CanSend => true;
public bool HasRequiredComponents(uint spellId) => true;
public bool IsTargetCompatible(
uint targetId,
SpellMetadata spell,
bool showMessage) => true;
public void StopCompletely() { }
public void SendUntargeted(uint spellId) { }
public void SendTargeted(uint targetId, uint spellId) { }
public void DisplayMessage(string message) { }
public void IncrementBusy() { }
}
}

View file

@ -1,5 +1,6 @@
using AcDream.App.Plugins;
using AcDream.App.UI;
using AcDream.Plugin.Abstractions;
namespace AcDream.App.Tests.Plugins;
@ -32,6 +33,8 @@ public class BufferedUiRegistryTests
var element = new UiPanel();
root.AddChild(element);
registry.CompleteMount(pending, root, element);
bool windowRemoved = false;
registry.CompleteWindowMount(pending, () => windowRemoved = true);
Assert.Contains(element, root.Children);
Assert.Equal(1, registry.RegistrationCount);
@ -40,5 +43,92 @@ public class BufferedUiRegistryTests
Assert.DoesNotContain(element, root.Children);
Assert.Equal(0, registry.RegistrationCount);
Assert.True(windowRemoved);
}
[Fact]
public void FirstClassPanelCarriesManifestOwnerAndStableWindowIdentity()
{
var registry = new BufferedUiRegistry();
var descriptor = new PluginPanelDescriptor("main", "MossTank")
{
IconText = "MT",
StartVisible = false,
};
registry.RegisterPanel(
new PluginUiOwner("acdream.mosstank", "MossTank"),
descriptor,
"mosstank.xml",
new object());
BufferedUiRegistry.Pending pending = Assert.Single(registry.Drain());
Assert.Equal("acdream.mosstank", pending.Owner.Id);
Assert.Same(descriptor, pending.Descriptor);
Assert.Equal("plugin:acdream.mosstank:main", pending.WindowName);
Assert.False(pending.Descriptor.StartVisible);
}
[Fact]
public void InlinePanelContentHasAnIndependentlyRemovableLifetime()
{
var registry = new BufferedUiRegistry();
IDisposable token = registry.RegisterPanelContent(
new PluginUiOwner("acdream.mosstank", "MossTank"),
new PluginPanelDescriptor("meta-status", "Status"),
"<panel w=\"100\" h=\"50\" />",
new object());
BufferedUiRegistry.Pending pending = Assert.Single(registry.Drain());
Assert.Equal("<panel w=\"100\" h=\"50\" />", pending.MarkupContent);
Assert.Equal("plugin:acdream.mosstank:meta-status", pending.WindowName);
token.Dispose();
Assert.Equal(0, registry.RegistrationCount);
}
[Fact]
public void LateWindowPublicationCleansUpAfterConcurrentDisposal()
{
var registry = new BufferedUiRegistry();
IDisposable token = registry.RegisterMarkupPanel("late.xml", new object());
BufferedUiRegistry.Pending pending = Assert.Single(registry.Drain());
token.Dispose();
bool cleaned = false;
registry.CompleteWindowMount(pending, () => cleaned = true);
Assert.True(cleaned);
Assert.Equal(0, registry.RegistrationCount);
}
[Fact]
public void ScopedViewsExposeOnlyTheirOwnNamedControls()
{
var registry = new BufferedUiRegistry();
var owner = new PluginUiOwner("acdream.mosstank", "MossTank");
registry.RegisterPanelContent(
owner,
new PluginPanelDescriptor("meta", "Status View"),
"<panel />",
new object());
BufferedUiRegistry.Pending pending = Assert.Single(registry.Drain());
var root = new UiRoot();
var panel = new UiPanel();
var button = new UiSimpleButton { Name = "Action", Text = "Old" };
panel.AddChild(button);
root.AddChild(panel);
registry.CompleteMount(pending, root, panel);
Assert.True(registry.ViewExists(owner, "Status View"));
Assert.True(registry.IsViewVisible(owner, "meta"));
Assert.True(registry.ControlExists(owner, "Status View", "Action"));
Assert.True(registry.SetControlLabel(owner, "Status View", "Action", "New"));
Assert.Equal("New", button.Text);
Assert.True(registry.SetControlVisible(
owner, "Status View", "Action", false));
Assert.False(button.Visible);
Assert.False(registry.ViewExists(
new PluginUiOwner("another.plugin", "Other"), "Status View"));
}
}

View file

@ -526,20 +526,29 @@ public sealed class ExternalRenderPackPackageLifecycleTests
private static string FixtureAssemblyPath()
{
const string projectName = "AcDream.Plugin.Tests.Fixtures.HostPlugin";
string colocated = Path.Combine(AppContext.BaseDirectory, projectName + ".dll");
if (File.Exists(colocated))
return colocated;
string configuration = new DirectoryInfo(AppContext.BaseDirectory)
.Parent!.Name;
return Path.Combine(
FindRepoRoot(),
"tests",
"AcDream.Plugin.Tests.Fixtures.HostPlugin",
projectName,
"bin",
configuration,
"net10.0",
"AcDream.Plugin.Tests.Fixtures.HostPlugin.dll");
projectName + ".dll");
}
private static string FixtureAssemblyPath(string projectName)
{
string colocated = Path.Combine(AppContext.BaseDirectory, projectName + ".dll");
if (File.Exists(colocated))
return colocated;
string configuration = new DirectoryInfo(AppContext.BaseDirectory)
.Parent!.Name;
return Path.Combine(

View file

@ -0,0 +1,35 @@
using AcDream.App.Plugins;
namespace AcDream.App.Tests.Plugins;
public sealed class FilePluginStorageTests
{
[Fact]
public void WriteReadReplaceAndDeleteStayUnderConfiguredRoot()
{
string root = Path.Combine(
Path.GetTempPath(),
$"acdream-plugin-storage-{Guid.NewGuid():N}");
try
{
var storage = new FilePluginStorage(root);
storage.WriteText("plugin/profile.json", "one");
storage.WriteText("plugin/imports/route.nav", "nav");
Assert.Equal("one", storage.ReadText("plugin/profile.json"));
storage.WriteText("plugin/profile.json", "two");
Assert.Equal("two", storage.ReadText("plugin/profile.json"));
Assert.Equal(
["plugin/imports/route.nav"],
storage.List("plugin/imports"));
Assert.True(storage.Delete("plugin/profile.json"));
Assert.Null(storage.ReadText("plugin/profile.json"));
Assert.Throws<ArgumentException>(() =>
storage.WriteText("../escape.json", "bad"));
}
finally
{
if (Directory.Exists(root))
Directory.Delete(root, recursive: true);
}
}
}

View file

@ -232,6 +232,10 @@ public sealed class GraphicalPluginSessionTests
"fixture-panel.xml",
panel.MarkupPath,
StringComparison.Ordinal);
Assert.Equal(FixtureId, panel.Owner.Id);
Assert.Equal("Host fixture", panel.Owner.DisplayName);
Assert.Equal("fixture-panel", panel.Descriptor.WindowId);
Assert.Equal("Host fixture", panel.Descriptor.Title);
Assert.Equal(
"AcDream.Plugin.Tests.Fixtures.HostPlugin",
panel.Binding.GetType().Assembly.GetName().Name);
@ -271,6 +275,11 @@ public sealed class GraphicalPluginSessionTests
private static string FixtureAssemblyPath()
{
string fileName = "AcDream.Plugin.Tests.Fixtures.HostPlugin.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);
@ -281,7 +290,7 @@ public sealed class GraphicalPluginSessionTests
"bin",
configuration,
"net10.0",
"AcDream.Plugin.Tests.Fixtures.HostPlugin.dll");
fileName);
}
private static string FindRepoRoot(string start)

View file

@ -0,0 +1,73 @@
using AcDream.App.Plugins;
using AcDream.Plugin.Abstractions;
namespace AcDream.App.Tests.Plugins;
public sealed class LocalPluginPeerRegistryTests
{
[Fact]
public void PublishesRemoteClientsIgnoresSelfAndExpiresStaleHeartbeat()
{
string root = Path.Combine(
Path.GetTempPath(),
$"acdream-plugin-peers-{Guid.NewGuid():N}");
var time = new ManualTimeProvider(
new DateTimeOffset(2026, 8, 27, 12, 0, 0, TimeSpan.Zero));
try
{
using var first = new LocalPluginPeerRegistry(
root,
time,
Guid.Parse("11111111-1111-1111-1111-111111111111"));
using var second = new LocalPluginPeerRegistry(
root,
time,
Guid.Parse("22222222-2222-2222-2222-222222222222"));
first.Publish(Client(first.ClientId, 10u, "Alpha", ["one"]));
second.Publish(Client(second.ClientId, 20u, "Beta", ["two"]));
PluginNetworkClient remote = Assert.Single(
first.CaptureRemoteClients());
Assert.Equal(second.ClientId, remote.ClientId);
Assert.Equal("Beta", remote.Name);
Assert.Equal(["two"], remote.Tags);
Assert.Equal(33.5d, remote.Position.EastWest);
time.Advance(LocalPluginPeerRegistry.StaleAfter
+ TimeSpan.FromMilliseconds(1));
Assert.Empty(first.CaptureRemoteClients());
}
finally
{
if (Directory.Exists(root))
Directory.Delete(root, recursive: true);
}
}
private static PluginNetworkClient Client(
uint clientId,
uint playerId,
string name,
IReadOnlyList<string> tags) => new(
clientId,
playerId,
name,
"Coldeve",
new PluginNavigationPosition(
0x7F7F0001u, 33.5d, -72.8d, 1d, 90f, true),
tags,
90u,
70u,
80u,
100u,
100u,
100u,
90f);
private sealed class ManualTimeProvider(DateTimeOffset utcNow) : TimeProvider
{
private DateTimeOffset _utcNow = utcNow;
public override DateTimeOffset GetUtcNow() => _utcNow;
public void Advance(TimeSpan elapsed) => _utcNow += elapsed;
}
}