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

@ -40,6 +40,7 @@ public sealed class ItemInteractionControllerTests
public readonly List<(uint VendorGuid, IReadOnlyList<(int Amount, uint ItemGuid)> Items, uint AlternateCurrencyId)> BuyAlls = new();
public bool SendBuyAllSucceeds = true;
public readonly List<(uint VendorGuid, IReadOnlyList<(int Amount, uint ItemGuid)> Items)> Sells = new();
public readonly List<(uint ToolGuid, IReadOnlyList<uint> ItemGuids)> Salvages = new();
public bool SendSellSucceeds = true;
public readonly List<string> Toasts = new();
public readonly List<string> SystemMessages = new();
@ -133,7 +134,12 @@ public sealed class ItemInteractionControllerTests
},
interfaceText: (text, type) => InterfaceTexts.Add((text, type)),
sendStackableMerge: (source, target, amount) =>
Merges.Add((source, target, amount)));
Merges.Add((source, target, amount)),
sendSalvage: (tool, items) =>
{
Salvages.Add((tool, items.ToArray()));
return true;
});
}
public ItemInteractionController Controller { get; }
@ -171,6 +177,86 @@ public sealed class ItemInteractionControllerTests
Assert.Empty(h.UseWithTarget);
}
[Fact]
public void AutomationApply_dispatchesDirectlyWithoutInstallingTargetMode()
{
var h = new Harness();
const uint source = 0x50000A21u;
h.AddContained(source, item =>
{
item.Useability = HealthKitUseability;
item.TargetType = (uint)ItemType.Creature;
});
Assert.True(h.Controller.TryApplyItem(source, Player));
Assert.Equal(new[] { (source, Player) }, h.UseWithTarget);
Assert.False(h.Controller.IsAnyTargetModeActive);
Assert.Equal(1, h.Controller.BusyCount);
}
[Fact]
public void AutomationApply_reportsRefusalWhenTargetIsIncompatible()
{
var h = new Harness();
const uint source = 0x50000A21u;
const uint coat = 0x50000A22u;
h.AddContained(source, item =>
{
item.Useability = HealthKitUseability;
item.TargetType = (uint)ItemType.Creature;
});
h.AddContained(coat, item => item.Type = ItemType.Armor);
Assert.False(h.Controller.TryApplyItem(source, coat));
Assert.Empty(h.UseWithTarget);
Assert.Equal(0, h.Controller.BusyCount);
}
[Fact]
public void AutomationUse_dispatchesOnlyAnOrdinaryWireUse()
{
var h = new Harness();
const uint item = 0x50000A23u;
h.AddContained(item, candidate =>
candidate.Useability = ItemUseability.Contained);
Assert.True(h.Controller.TryUseItemForAutomation(item));
Assert.Equal(new[] { item }, h.Uses);
Assert.Equal(1, h.Controller.BusyCount);
}
[Fact]
public void AutomationUse_refusesTargetedItemInsteadOfOpeningModalCursor()
{
var h = new Harness();
const uint item = 0x50000A24u;
h.AddContained(item, candidate =>
candidate.Useability = HealthKitUseability);
Assert.False(h.Controller.TryUseItemForAutomation(item));
Assert.Empty(h.Uses);
Assert.False(h.Controller.IsAnyTargetModeActive);
Assert.Equal(0, h.Controller.BusyCount);
}
[Fact]
public void AutomationAppraisalUsesCanonicalOwnerWithoutChangingSelectionMode()
{
var h = new Harness();
const uint item = 0x50000A25u;
h.AddContained(item);
Assert.True(h.Controller.TryAppraiseForAutomation(item));
Assert.Equal(new[] { item }, h.Examines);
Assert.False(h.Controller.IsAnyTargetModeActive);
Assert.Equal(1, h.Controller.BusyCount);
}
[Fact]
public void ResetSession_RetryNotifiesEveryStateObserver()
{
@ -2085,6 +2171,110 @@ public sealed class ItemInteractionControllerTests
Assert.False(h.Controller.TryGetPendingInventoryRequest(out _));
}
[Fact]
public void AutomationMoveUsesWholeOrExactPartialRetailRequest()
{
var whole = new Harness();
const uint wholeItem = 0x50000A32u;
whole.AddContained(wholeItem, item => item.StackSize = 10);
Assert.True(whole.Controller.TryMoveItemForAutomation(
wholeItem, Player, amount: 0u, placement: 7));
Assert.Equal(new[] { (wholeItem, Player, 7) }, whole.Puts);
Assert.True(whole.Controller.TryGetPendingInventoryRequest(out var put));
Assert.Equal(InventoryRequestKind.PutInContainer, put.Kind);
var partial = new Harness();
const uint partialItem = 0x50000A33u;
partial.AddContained(partialItem, item => item.StackSize = 10);
Assert.True(partial.Controller.TryMoveItemForAutomation(
partialItem, Player, amount: 2u, placement: 3));
Assert.Equal(
new[] { (partialItem, Player, 3u, 2u) },
partial.SplitPuts);
Assert.True(partial.Controller.TryGetPendingInventoryRequest(out var split));
Assert.Equal(InventoryRequestKind.SplitToContainer, split.Kind);
}
[Fact]
public void AutomationMergeUsesRetailPlannerAndSharedGate()
{
var h = new Harness();
const uint source = 0x50000A34u;
const uint target = 0x50000A35u;
h.AddContained(source, item =>
{
item.WeenieClassId = 77u;
item.StackSize = 8;
item.StackSizeMax = 10;
});
h.AddContained(target, item =>
{
item.WeenieClassId = 77u;
item.StackSize = 7;
item.StackSizeMax = 10;
});
Assert.True(h.Controller.TryMergeItemsForAutomation(source, target));
Assert.Equal(new[] { (source, target, 3u) }, h.Merges);
Assert.True(h.Controller.TryGetPendingInventoryRequest(out var pending));
Assert.Equal(InventoryRequestKind.Merge, pending.Kind);
Assert.False(h.Controller.TryMoveItemForAutomation(source, Player));
Assert.Single(h.Merges);
}
[Fact]
public void AutomationDropAndGivePreserveExactStackAmounts()
{
var drop = new Harness();
const uint dropItem = 0x50000A36u;
drop.AddContained(dropItem, item => item.StackSize = 10);
Assert.True(drop.Controller.TryDropItemForAutomation(dropItem, 2u));
Assert.Equal(new[] { (dropItem, 2u) }, drop.SplitDrops);
Assert.Empty(drop.Drops);
var give = new Harness();
const uint giveItem = 0x50000A37u;
const uint recipient = 0x70000A38u;
give.AddContained(giveItem, item => item.StackSize = 10);
give.Objects.AddOrUpdate(new ClientObject
{
ObjectId = recipient,
Name = "Recipient",
Type = ItemType.Creature,
});
Assert.True(give.Controller.TryGiveItemForAutomation(
giveItem, recipient, 4u));
Assert.Equal(new[] { (recipient, giveItem, 4u) }, give.Gives);
}
[Fact]
public void AutomationSalvageRequiresRetailToolAndSuitableOwnedItems()
{
var h = new Harness();
const uint tool = 0x50000A40u;
const uint source = 0x50000A41u;
h.AddContained(tool, item => item.Type = ItemType.TinkeringTool);
h.AddContained(source, item =>
{
item.MaterialType = 12u;
item.Structure = 50;
});
Assert.True(h.Controller.TrySalvageItemsForAutomation(tool, [source]));
Assert.Single(h.Salvages);
Assert.Equal(tool, h.Salvages[0].ToolGuid);
Assert.Equal(new[] { source }, h.Salvages[0].ItemGuids);
h.Objects.Get(source)!.Structure = 100;
Assert.False(h.Controller.TrySalvageItemsForAutomation(tool, [source]));
Assert.Single(h.Salvages);
}
[Fact]
public void MatchingInventoryFailureReleasesGlobalRequest()
{

View file

@ -0,0 +1,51 @@
using System.Numerics;
using AcDream.App.UI;
using AcDream.App.UI.Layout;
using AcDream.Plugin.Abstractions;
namespace AcDream.App.Tests.UI.Layout;
public sealed class ProjectileDebugOverlayControllerTests
{
[Fact]
public void ProjectsTransientClearAndBlockedSamplesWithoutConsumingInput()
{
IReadOnlyList<PluginProjectileDebugSample> samples =
[
new(new Vector3(0f, 0f, -10f), true, 0.4f),
new(new Vector3(1f, 0f, -10f), false, 0.4f),
];
var root = new UiRoot { Width = 800f, Height = 600f };
Matrix4x4 projection = Matrix4x4.CreatePerspectiveFieldOfView(
MathF.PI / 2f,
4f / 3f,
0.1f,
100f);
ProjectileDebugOverlayController controller =
ProjectileDebugOverlayController.Mount(
root,
() => samples,
() => (Matrix4x4.Identity, projection, new Vector2(800f, 600f)));
controller.Tick();
UiPanel overlay = Assert.IsType<UiPanel>(Assert.Single(root.Children));
Assert.True(overlay.Visible);
Assert.True(overlay.ClickThrough);
Assert.Equal(2, overlay.Children.Count);
UiPanel clear = Assert.IsType<UiPanel>(overlay.Children[0]);
UiPanel blocked = Assert.IsType<UiPanel>(overlay.Children[1]);
Assert.True(clear.Visible);
Assert.True(blocked.Visible);
Assert.Equal(new Vector4(0f, 1f, 0f, 0.95f), clear.BorderColor);
Assert.Equal(new Vector4(1f, 0f, 0f, 0.95f), blocked.BorderColor);
Assert.InRange(clear.Left, 380f, 400f);
Assert.InRange(clear.Top, 280f, 300f);
samples = [];
controller.Tick();
Assert.False(overlay.Visible);
Assert.All(overlay.Children, static child => Assert.False(child.Visible));
}
}

View file

@ -4,6 +4,79 @@ namespace AcDream.App.Tests.UI;
public class MarkupDocumentTests
{
private sealed class EditorBinding
{
public string Draft { get; private set; } = "initial";
public string Submitted { get; private set; } = string.Empty;
public string Selected { get; private set; } = "First";
public int SelectedIndex { get; private set; }
public IReadOnlyList<string> Choices => ["First", "Second"];
public IReadOnlyList<uint> ChoiceColors => [0xFF0000u, 0x00FF00u];
public Action<string> ChangeDraft => value => Draft = value;
public Action<string> SubmitDraft => value => Submitted = value;
public Action<string> SelectChoice => value => Selected = value;
public Action<int> SelectIndex => value => SelectedIndex = value;
}
[Fact]
public void FieldAndMenuBindEditablePluginState()
{
const string xml = """
<panel x="0" y="0" w="240" h="120">
<field x="4" y="4" w="120" h="20" text="{Draft}"
onchange="{ChangeDraft}" onsubmit="{SubmitDraft}" />
<menu x="4" y="32" w="120" h="20" items="{Choices}"
selected="{Selected}" onchange="{SelectChoice}" />
<list x="132" y="4" w="100" h="40" items="{Choices}"
colors="{ChoiceColors}"
selected="{SelectedIndex}" onchange="{SelectIndex}" />
</panel>
""";
var binding = new EditorBinding();
UiNineSlicePanel panel = MarkupDocument.Build(
xml,
binding,
_ => (1u, 32, 32));
UiField field = Assert.IsType<UiField>(panel.Children[0]);
UiMenu menu = Assert.IsType<UiMenu>(panel.Children[1]);
UiMarkupList list = Assert.IsType<UiMarkupList>(panel.Children[2]);
field.SetText("named profile");
field.OnSubmit?.Invoke(field.Text);
menu.OnSelect?.Invoke("Second");
list.OnEvent(new UiEvent
{
Type = UiEventType.MouseDown,
Data2 = 19,
});
Assert.Equal("named profile", binding.Draft);
Assert.Equal("named profile", binding.Submitted);
Assert.Equal("Second", binding.Selected);
Assert.Equal(1, binding.SelectedIndex);
Assert.Equal(2, menu.Items.Count);
Assert.Equal([0xFF0000u, 0x00FF00u], list.ItemColorsSource());
Assert.False(menu.OpenUpward);
}
[Fact]
public void ControlIdAndNameBecomeStablePluginControlNames()
{
const string xml = """
<panel x="0" y="0" w="200" h="80">
<button id="ById" x="4" y="4" w="80" h="20" text="One" />
<label name="ByName" x="4" y="28" text="Two" />
</panel>
""";
UiNineSlicePanel panel = MarkupDocument.Build(
xml, new object(), _ => (1u, 32, 32));
Assert.Equal("ById", panel.Children[0].Name);
Assert.Equal("ByName", panel.Children[1].Name);
}
private sealed class FakeBinding
{
public float HealthPercent => 0.5f;
@ -82,6 +155,14 @@ public class MarkupDocumentTests
public string Status { get; set; } = "idle";
public Action Go => () => Clicks++;
public Action? Missing => null;
public bool CanGo { get; set; } = true;
public bool OptionsSelected { get; set; } = true;
public bool OptionsVisible { get; set; } = true;
public bool CombatEnabled { get; set; }
public float AttackPower { get; private set; } = 0.5f;
public Action ShowOptions => () => OptionsSelected = true;
public Action ToggleCombat => () => CombatEnabled = !CombatEnabled;
public Action<float> SetAttackPower => value => AttackPower = value;
}
[Fact]
@ -148,4 +229,89 @@ public class MarkupDocumentTests
var label = Assert.IsType<UiLabel>(panel.Children[0]);
Assert.Equal("MossTank", label.TextSource!());
}
[Fact]
public void Build_ProjectsBoundEnabledAndButtonColors()
{
const string xml =
"<panel x=\"0\" y=\"0\" w=\"100\" h=\"60\">" +
"<button x=\"0\" y=\"0\" w=\"50\" h=\"20\" text=\"Go\" " +
"onclick=\"{Go}\" enabled=\"{CanGo}\" " +
"background=\"#FF112233\" border=\"#FF445566\"/>" +
"</panel>";
var binding = new ButtonBinding();
UiNineSlicePanel panel = MarkupDocument.Build(
xml, binding, _ => ((uint)1, 32, 32));
var button = Assert.IsType<UiSimpleButton>(panel.Children[0]);
Assert.NotNull(button.EnabledSource);
Assert.True(button.EnabledSource!());
binding.CanGo = false;
Assert.False(button.EnabledSource!());
Assert.Equal(new System.Numerics.Vector4(
0x11 / 255f, 0x22 / 255f, 0x33 / 255f, 1f),
button.BackgroundColor);
Assert.Equal(new System.Numerics.Vector4(
0x44 / 255f, 0x55 / 255f, 0x66 / 255f, 1f),
button.BorderColor);
}
[Fact]
public void Build_RuntimeTooltipUsesRetailPopupLocatorAndLiveBinding()
{
const string xml =
"<panel x=\"0\" y=\"0\" w=\"100\" h=\"60\">" +
"<button x=\"0\" y=\"0\" w=\"50\" h=\"20\" text=\"Go\" " +
"tooltip=\"{Status}\"/>" +
"</panel>";
var binding = new ButtonBinding();
UiNineSlicePanel panel = MarkupDocument.Build(
xml, binding, _ => ((uint)1, 32, 32));
var button = Assert.IsType<UiSimpleButton>(panel.Children[0]);
Assert.Equal("idle", button.GetTooltipText());
Assert.True(button.AuthoredTooltipEnabled);
Assert.Equal(0x10000397u, button.AuthoredTooltipRootElementId);
Assert.Equal(0x21000041u, button.AuthoredTooltipLayoutDid);
binding.Status = "casting";
Assert.Equal("casting", button.GetTooltipText());
}
[Fact]
public void Build_NestedGroupTabToggleAndSliderStayLiveAndInteractive()
{
const string xml =
"<panel x=\"0\" y=\"0\" w=\"300\" h=\"180\">" +
"<tab x=\"4\" y=\"4\" w=\"60\" h=\"18\" text=\"Options\" " +
"selected=\"{OptionsSelected}\" onclick=\"{ShowOptions}\"/>" +
"<group x=\"4\" y=\"28\" w=\"280\" h=\"140\" visible=\"{OptionsVisible}\">" +
"<toggle x=\"2\" y=\"2\" w=\"140\" h=\"20\" text=\"Enable Combat\" " +
"checked=\"{CombatEnabled}\" onclick=\"{ToggleCombat}\"/>" +
"<slider x=\"2\" y=\"30\" w=\"140\" h=\"16\" value=\"{AttackPower}\" " +
"onchange=\"{SetAttackPower}\"/>" +
"</group></panel>";
var binding = new ButtonBinding();
UiNineSlicePanel panel = MarkupDocument.Build(
xml,
binding,
_ => ((uint)1, 16, 16));
var tab = Assert.IsType<UiMarkupTabButton>(panel.Children[0]);
var group = Assert.IsType<UiPanel>(panel.Children[1]);
var toggle = Assert.IsType<UiMarkupToggle>(group.Children[0]);
var slider = Assert.IsType<UiScrollbar>(group.Children[1]);
Assert.True(tab.IsSelected);
Assert.True(group.VisibleSource!());
Assert.False(toggle.IsChecked);
toggle.OnEvent(new UiEvent { Type = UiEventType.Click });
Assert.True(binding.CombatEnabled);
Assert.True(toggle.IsChecked);
slider.ScalarChanged!(0.78f);
Assert.Equal(0.78f, binding.AttackPower);
Assert.Equal(0.78f, slider.ScalarPositionSource!());
}
}

View file

@ -0,0 +1,132 @@
using AcDream.App.UI;
using AcDream.Plugin.Abstractions;
namespace AcDream.App.Tests.UI;
public sealed class PluginSidePanelTests
{
[Fact]
public void ManyPluginsWrapIntoReachableColumnsWithinTheLiveScreenHeight()
{
var root = new UiRoot { Width = 800f, Height = 260f };
using var shelf = new PluginSidePanel(
root.WindowManager,
_ => (0u, 0, 0),
font: null);
root.AddChild(shelf);
for (int i = 0; i < 12; i++)
{
var frame = new UiPanel { Width = 200f, Height = 100f };
root.AddChild(frame);
RetailWindowHandle handle = root.WindowManager.Register(
$"plugin:test:{i}",
frame);
shelf.Add(
new PluginUiOwner($"test.{i}", $"Plugin {i}"),
new PluginPanelDescriptor("main", $"Plugin {i}"),
handle);
}
root.Tick(0.016d, 16L);
Assert.Equal(12, shelf.EntryCount);
Assert.True(shelf.Width > 36f);
Assert.True(shelf.Top + shelf.Height <= root.Height);
Assert.All(
shelf.Children,
child => Assert.True(child.Top + child.Height <= shelf.Height));
Assert.Equal(root.Width - shelf.Width - 4f, shelf.Left);
}
[Fact]
public void ShelfAndMinimizeButtonsHideAndRestoreWithoutUnregisteringWindow()
{
var root = new UiRoot { Width = 1280f, Height = 720f };
var frame = new UiPanel
{
Width = 320f,
Height = 180f,
Visible = true,
};
root.AddChild(frame);
RetailWindowHandle handle = root.WindowManager.Register(
"plugin:acdream.test:main",
frame);
using var shelf = new PluginSidePanel(
root.WindowManager,
_ => (0u, 0, 0),
font: null);
root.AddChild(shelf);
shelf.Add(
new PluginUiOwner("acdream.test", "Test Plugin"),
new PluginPanelDescriptor("main", "Test Plugin")
{
IconText = "TP",
},
handle);
Assert.True(shelf.Visible);
Assert.Equal(1, shelf.EntryCount);
UiSimpleButton shelfButton = Assert.IsAssignableFrom<UiSimpleButton>(
Assert.Single(shelf.Children));
shelfButton.OnEvent(new UiEvent { Type = UiEventType.Click });
Assert.False(handle.IsVisible);
Assert.True(handle.IsRegistered);
shelfButton.OnEvent(new UiEvent { Type = UiEventType.Click });
Assert.True(handle.IsVisible);
UiSimpleButton minimize = Assert.IsAssignableFrom<UiSimpleButton>(
Assert.Single(frame.Children));
minimize.OnEvent(new UiEvent { Type = UiEventType.Click });
Assert.False(handle.IsVisible);
Assert.True(handle.IsRegistered);
root.WindowManager.Unregister(handle.Name);
Assert.Equal(0, shelf.EntryCount);
Assert.False(shelf.Visible);
}
[Fact]
public void FullWidthPluginWindowStartsAndStaysReachableAtMinimumCanvas()
{
var root = new UiRoot { Width = 800f, Height = 600f };
var frame = new UiPanel
{
Left = 28f,
Top = 42f,
Width = 800f,
Height = 244f,
Visible = true,
};
root.AddChild(frame);
RetailWindowHandle handle = root.WindowManager.Register(
"plugin:acdream.mosstank:main",
frame);
using var shelf = new PluginSidePanel(
root.WindowManager,
_ => (0u, 0, 0),
font: null);
root.AddChild(shelf);
shelf.Add(
new PluginUiOwner("acdream.mosstank", "MossTank"),
new PluginPanelDescriptor("main", "MossTank"),
handle);
Assert.Equal(0f, handle.Left);
Assert.Equal(42f, handle.Top);
Assert.True(frame.ConstrainDragToParent);
Assert.True(frame.ConstrainResizeToParent);
frame.Left = 700f;
frame.Top = 590f;
root.Tick(0.016d, 16L);
Assert.Equal(0f, handle.Left);
Assert.Equal(356f, handle.Top);
}
}