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

@ -34,6 +34,21 @@ public sealed class DispatcherMovementInputSourceTests
Assert.False(captured.Run);
}
[Fact]
public void CommandInputIsMarkedPersistentWithoutChangingStoredSnapshot()
{
using var movement = new RuntimeLocalPlayerMovementState();
var command = new MovementInput(TurnRight: true);
movement.SetCommandInput(command);
var source = new DispatcherMovementInputSource(movement);
MovementInput captured = source.Capture();
Assert.True(captured.TurnRight);
Assert.True(captured.IsPersistentCommand);
Assert.Equal(command, movement.CommandInput);
}
[Fact]
public void RetainedKeyboardCaptureSilencesHeldKeysButDoesNotCancelAutorun()
{

View file

@ -0,0 +1,46 @@
using AcDream.App.Platform;
namespace AcDream.App.Tests.Platform;
public sealed class Win32GlfwActiveWindowGuardTests
{
[Fact]
public void CurrentProcessWindowRemainsVisibleToGlfw()
{
nint window = (nint)0x1234;
Assert.Equal(
window,
Win32GlfwActiveWindowGuard.AcceptWindow(
window,
ownerProcessId: 47,
currentProcessId: 47));
}
[Fact]
public void ForeignProcessWindowBecomesGlfwsExistingNoWindowPath()
{
Assert.Equal(
0,
Win32GlfwActiveWindowGuard.AcceptWindow(
(nint)0x1234,
ownerProcessId: 48,
currentProcessId: 47));
}
[Theory]
[InlineData(0, 47, 47)]
[InlineData(0x1234, 0, 47)]
public void MissingOrUnownedWindowIsRejected(
long window,
uint ownerProcessId,
uint currentProcessId)
{
Assert.Equal(
0,
Win32GlfwActiveWindowGuard.AcceptWindow(
(nint)window,
ownerProcessId,
currentProcessId));
}
}

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;
}
}

View file

@ -109,7 +109,7 @@ public sealed class LinuxPlatformBoundaryTests
}
[Fact]
public void SmokePluginCopyUsesRidAwarePortableBuildAndPublishPaths()
public void ShippedPluginCopiesUseResolvedTargetPathsForBuildAndPublish()
{
string project = File.ReadAllText(Path.Combine(
AppSourceRoot(),
@ -119,8 +119,16 @@ public sealed class LinuxPlatformBoundaryTests
Assert.Contains("$(RuntimeIdentifier)", project, StringComparison.Ordinal);
Assert.Contains("$(OutputPath)plugins/", project, StringComparison.Ordinal);
Assert.Contains("$(PublishDir)plugins/", project, StringComparison.Ordinal);
Assert.Equal(
4,
project.Split("Targets=\"GetTargetPath\"", StringSplitOptions.None)
.Length - 1);
Assert.Contains(
"../AcDream.Plugins.MossTank/mosstank.xml",
project,
StringComparison.Ordinal);
Assert.DoesNotContain(
@"bin\$(Configuration)\net10.0",
"/bin/$(Configuration)",
project,
StringComparison.Ordinal);
}

View file

@ -167,6 +167,22 @@ public sealed class RuntimeOptionsTests
Assert.False(opts.ExactAutomationFramebuffer);
Assert.False(opts.UiProbeEnabled);
Assert.False(opts.HasLiveCredentials);
Assert.Empty(opts.PluginTags);
}
[Fact]
public void PluginPeerTagsAreParsedOnceBoundedAndCaseInsensitive()
{
string oversized = new('x', 129);
RuntimeOptions options = RuntimeOptions.Parse(
AnyDatDir,
Env(new()
{
["ACDREAM_PLUGIN_TAGS"] =
$" healer,Leader,HEALER,,{oversized}, scout ",
}));
Assert.Equal(["healer", "Leader", "scout"], options.PluginTags);
}
[Fact]

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);
}
}

View file

@ -8,6 +8,31 @@ namespace AcDream.Core.Net.Tests.Messages;
public sealed class InventoryActionsTests
{
[Fact]
public void CreateTinkeringToolMatchesRetailToolThenPackableGuidList()
{
byte[] body = InventoryActions.BuildCreateTinkeringTool(
7u,
0x50000010u,
[0x50000020u, 0x50000030u]);
Assert.Equal(28, body.Length);
Assert.Equal(InventoryActions.GameActionEnvelope,
BinaryPrimitives.ReadUInt32LittleEndian(body));
Assert.Equal(7u,
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(4)));
Assert.Equal(InventoryActions.CreateTinkeringToolOpcode,
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8)));
Assert.Equal(0x50000010u,
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(12)));
Assert.Equal(2u,
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(16)));
Assert.Equal(0x50000020u,
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(20)));
Assert.Equal(0x50000030u,
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(24)));
}
[Fact]
public void BuildStackableMerge_CarriesBothGuidsAndAmount()
{

View file

@ -5,6 +5,24 @@ namespace AcDream.Core.Net.Tests;
public sealed class WorldSessionInventoryActionTests
{
[Fact]
public void SendSalvageUsesNextSequenceAndRetailBody()
{
using var session = new WorldSession(
new IPEndPoint(IPAddress.Loopback, 65000));
byte[]? captured = null;
session.GameActionCapture = body => captured = body;
session.SendSalvage(0x50000010u, [0x50000020u]);
Assert.Equal(
InventoryActions.BuildCreateTinkeringTool(
1u,
0x50000010u,
[0x50000020u]),
captured);
}
[Fact]
public void SendStackableMerge_UsesNextSequenceAndExactBuilderBytes()
{

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

View file

@ -395,6 +395,11 @@ public sealed class HeadlessPluginSessionTests
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);
@ -405,7 +410,7 @@ public sealed class HeadlessPluginSessionTests
"bin",
configuration,
"net10.0",
"AcDream.Plugin.Tests.Fixtures.HostPlugin.dll");
fileName);
}
private static string FindRepoRoot(string start)

View file

@ -2736,6 +2736,21 @@ public sealed class HeadlessSessionHostTests
Assert.False(frameHost.CanAdvancePlayer);
}
[Fact]
public void CommandMovementFrameIsMarkedPersistentWithoutMutatingOwner()
{
using var movement = new RuntimeLocalPlayerMovementState();
var command = new MovementInput(TurnLeft: true);
movement.SetCommandInput(command);
var source = new HeadlessMovementInputSource(movement);
MovementInput captured = source.Capture();
Assert.True(captured.TurnLeft);
Assert.True(captured.IsPersistentCommand);
Assert.Equal(command, movement.CommandInput);
}
private static LiveSessionHost CreateInertLiveSessionHost()
{
var controller = new LiveSessionController(

View file

@ -19,4 +19,9 @@
<ItemGroup>
<ProjectReference Include="..\..\src\AcDream.Plugins.MossTank\AcDream.Plugins.MossTank.csproj" />
</ItemGroup>
<ItemGroup>
<None Include="..\..\src\AcDream.Plugins.MossTank\mosstank.xml"
Link="mosstank.xml"
CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,347 @@
using AcDream.Plugin.Abstractions;
namespace AcDream.Plugins.MossTank.Tests;
public sealed class AttackSpellCatalogTests
{
[Theory]
[InlineData("Force Bolt VII", "piercing damage", 2)]
[InlineData("Shock Wave VII", "bludgeoning damage", 3)]
[InlineData("Whirling Blade VII", "slashing damage", 1)]
[InlineData("Nether Bolt VII", "nether damage", 8)]
public void RetailDescriptionDeterminesDamageElement(
string name,
string description,
int expected)
{
PluginSpellInfo spell = Spell(1, name, description) with
{
TargetMask = 0x10,
IsProjectile = true,
};
Assert.True(AttackSpellCatalog.TryClassify(spell, out var choice));
Assert.Equal((MonsterDamageType)expected, choice.DamageType);
Assert.Equal(AttackSpellShape.Direct, choice.Shape);
}
[Theory]
[InlineData("Incantation of Piercing Lure")]
[InlineData("Piercing Vulnerability Other VII")]
public void VulnerabilityDebuffsAreNeverClassifiedAsDamageSpells(string name)
{
PluginSpellInfo spell = Spell(
1,
name,
"Makes the target more vulnerable to piercing damage.") with
{
TargetMask = 0x10,
IsDebuff = true,
};
Assert.False(AttackSpellCatalog.TryClassify(spell, out _));
}
[Fact]
public void ArcIsPreferredOnlyAtOrBeyondArcRange()
{
var catalog = AttackSpellCatalog.Build(
[
Spell(1, "Flame Bolt VII", "fire damage") with
{
TargetMask = 0x10,
IsProjectile = true,
},
Spell(2, "Flame Arc VII", "fire damage") with
{
TargetMask = 0x10,
IsProjectile = true,
},
]);
var settings = new CombatSettings { UseArcs = true, ArcRange = 15 };
var actions = new MonsterRuleActions
{
DamageType = MonsterDamageType.Fire,
};
Assert.Equal(AttackSpellShape.Direct, catalog.Candidates(
actions, settings, Target(5), 0, Character.Instance)[0].Shape);
Assert.Equal(AttackSpellShape.Arc, catalog.Candidates(
actions, settings, Target(20), 0, Character.Instance)[0].Shape);
}
[Fact]
public void StreakFlagPrefersStreakAndRetainsDirectFallback()
{
var catalog = AttackSpellCatalog.Build(
[
Spell(1, "Flame Bolt VII", "fire damage") with
{
TargetMask = 0x10,
IsProjectile = true,
},
Spell(2, "Flame Streak VII", "fire damage") with
{
TargetMask = 0x10,
IsProjectile = true,
},
]);
var actions = new MonsterRuleActions
{
Flags = MonsterActionFlags.Attack | MonsterActionFlags.Streak,
DamageType = MonsterDamageType.Fire,
};
IReadOnlyList<AttackSpellChoice> choices = catalog.Candidates(
actions, new CombatSettings(), Target(5), 0, Character.Instance);
Assert.Equal(AttackSpellShape.Streak, choices[0].Shape);
Assert.Contains(choices, choice => choice.Shape == AttackSpellShape.Direct);
}
[Fact]
public void AttackPlusRingRequiresThresholdButRingOnlyRequiresOne()
{
var catalog = AttackSpellCatalog.Build(
[
Spell(1, "Flame Bolt VII", "fire damage") with
{
TargetMask = 0x10,
IsProjectile = true,
},
Spell(2, "Flame Ring", "fire damage outward from the caster") with
{
TargetMask = 0,
},
]);
var settings = new CombatSettings { MinimumRingTargets = 4 };
var attackAndRing = new MonsterRuleActions
{
Flags = MonsterActionFlags.Attack | MonsterActionFlags.Ring,
DamageType = MonsterDamageType.Fire,
};
var ringOnly = attackAndRing with { Flags = MonsterActionFlags.Ring };
Assert.Equal(AttackSpellShape.Direct, catalog.Candidates(
attackAndRing, settings, Target(3), 3, Character.Instance)[0].Shape);
Assert.Equal(AttackSpellShape.Ring, catalog.Candidates(
attackAndRing, settings, Target(3), 4, Character.Instance)[0].Shape);
Assert.Equal(AttackSpellShape.Ring, catalog.Candidates(
ringOnly, settings, Target(3), 1, Character.Instance)[0].Shape);
}
[Fact]
public void HarmAndVoidModesDoNotCrossSelectSpellFamilies()
{
var catalog = AttackSpellCatalog.Build(
[
Spell(1, "Harm Other VII", "Drains the target's Health."),
Spell(2, "Nether Bolt VII", "nether damage") with
{
TargetMask = 0x10,
IsProjectile = true,
},
]);
Assert.All(catalog.Candidates(
new MonsterRuleActions { DamageType = MonsterDamageType.Harm },
new CombatSettings(), Target(3), 0, Character.Instance),
choice => Assert.Equal(AttackSpellShape.Harm, choice.Shape));
Assert.All(catalog.Candidates(
new MonsterRuleActions { DamageType = MonsterDamageType.VoidBasic },
new CombatSettings(), Target(3), 0, Character.Instance),
choice => Assert.Equal(MonsterDamageType.Nether, choice.DamageType));
}
[Fact]
public void AutoUsesVoidWhenWarIsUntrainedAndVoidIsTrained()
{
var catalog = AttackSpellCatalog.Build(
[
Spell(1, "Flame Bolt VII", "fire damage") with
{
TargetMask = 0x10,
IsProjectile = true,
},
Spell(2, "Nether Bolt VII", "nether damage") with
{
TargetMask = 0x10,
IsProjectile = true,
},
]);
var character = new Character(
[
new PluginSkillInfo(
43,
"Void Magic",
PluginSkillTraining.Trained,
400),
]);
IReadOnlyList<AttackSpellChoice> choices = catalog.Candidates(
new MonsterRuleActions { DamageType = MonsterDamageType.Auto },
new CombatSettings(),
Target(3),
0,
character);
Assert.NotEmpty(choices);
Assert.All(choices, choice =>
Assert.Equal(MonsterDamageType.Nether, choice.DamageType));
}
[Fact]
public void AutoUsesOfficialMonsterOverrideBeforeSpellShapeOrTier()
{
var catalog = AttackSpellCatalog.Build(
[
Spell(1, "Flame Bolt VII", "fire damage") with
{
TargetMask = 0x10,
IsProjectile = true,
},
Spell(2, "Frost Arc VI", "cold damage") with
{
Tier = 6,
TargetMask = 0x10,
IsProjectile = true,
},
]);
PluginCombatTarget target = Target(5) with
{
Name = "Magma Golem",
SpeciesId = 1,
};
IReadOnlyList<AttackSpellChoice> choices = catalog.Candidates(
new MonsterRuleActions { DamageType = MonsterDamageType.Auto },
new CombatSettings { UseArcs = false },
target,
0,
Character.Instance);
Assert.Equal(MonsterDamageType.Cold, choices[0].DamageType);
Assert.Equal(AttackSpellShape.Arc, choices[0].Shape);
}
[Fact]
public void PrismaticRetainsAutomaticMagicElementSelection()
{
var catalog = AttackSpellCatalog.Build(
[
Spell(1, "Flame Bolt VII", "fire damage") with
{
TargetMask = 0x10,
IsProjectile = true,
},
Spell(2, "Frost Bolt VII", "cold damage") with
{
TargetMask = 0x10,
IsProjectile = true,
},
]);
PluginCombatTarget target = Target(5) with
{
Name = "Magma Golem",
SpeciesId = 1,
};
IReadOnlyList<AttackSpellChoice> choices = catalog.Candidates(
new MonsterRuleActions { DamageType = MonsterDamageType.Prismatic },
new CombatSettings(),
target,
0,
Character.Instance);
Assert.NotEmpty(choices);
Assert.Equal(MonsterDamageType.Cold, choices[0].DamageType);
}
[Fact]
public void FistsUsesTuskerSpellOnlyWhileTuskerFistsEnchantmentIsActive()
{
const uint tuskerFists = 0x0B76u;
var catalog = AttackSpellCatalog.Build(
[
Spell(tuskerFists, "Tusker Fists", string.Empty) with
{
TargetMask = 0x10,
},
Spell(2, "Shock Wave VII", "bludgeoning damage") with
{
TargetMask = 0x10,
IsProjectile = true,
},
]);
var active = new Character(
enchantments:
[
new PluginActiveEnchantment(
tuskerFists,
Family: tuskerFists,
Tier: 1,
SecondsRemaining: 60),
]);
Assert.Equal(tuskerFists, catalog.Candidates(
new MonsterRuleActions { DamageType = MonsterDamageType.Fists },
new CombatSettings(), Target(3), 0, active)[0].Spell.SpellId);
Assert.Equal(MonsterDamageType.Bludgeon, catalog.Candidates(
new MonsterRuleActions { DamageType = MonsterDamageType.Fists },
new CombatSettings(), Target(3), 0, Character.Instance)[0].DamageType);
}
private static PluginSpellInfo Spell(
uint id,
string name,
string description) => new(
id,
name,
Family: id,
Tier: 7,
Difficulty: 300,
ManaCost: 35,
DurationSeconds: 0,
School: 34,
Description: description,
IsSelfTargeted: false,
IsBeneficial: false)
{
IsOffensive = true,
};
private static PluginCombatTarget Target(float distance) => new(
10, "Target", 100, distance, 0, true, 1f);
private sealed class Character(
IReadOnlyList<PluginSkillInfo>? skills = null,
IReadOnlyList<PluginActiveEnchantment>? enchantments = null) : ICharacterInfo
{
public static Character Instance { get; } = new();
public bool IsInWorld => true;
public uint ObjectId => 1;
public uint CurrentHealth => 100;
public uint MaxHealth => 100;
public uint CurrentStamina => 100;
public uint MaxStamina => 100;
public uint CurrentMana => 100;
public uint MaxMana => 100;
public IReadOnlyList<PluginSkillInfo> Skills => skills ?? [];
public IReadOnlyList<PluginAttributeInfo> Attributes => [];
public IReadOnlyList<PluginActiveEnchantment> ActiveEnchantments =>
enchantments ?? [];
public bool TryGetSkill(uint skillId, out PluginSkillInfo skill)
{
foreach (PluginSkillInfo candidate in Skills)
{
if (candidate.SkillId == skillId)
{
skill = candidate;
return true;
}
}
skill = default;
return false;
}
}
}

View file

@ -0,0 +1,118 @@
using AcDream.Plugin.Abstractions;
namespace AcDream.Plugins.MossTank.Tests;
public sealed class AutoAttackPowerTests
{
[Theory]
[InlineData(1, 1, 0, false, false, 0.5f)]
[InlineData(2, 1, 0, false, false, 0f)]
[InlineData(2, 0, 0, false, false, 0.2f)]
[InlineData(2, 0, 0x40, true, false, 0.49f)]
[InlineData(2, 0, 0x40, false, true, 1f)]
public void MeleeHybridDecisionTreeMatchesOfficialVtank(
int requestedRaw,
int weaponType,
int attackType,
bool dualWield,
bool shield,
float expected)
{
PluginInventoryItem weapon = Weapon(
1, 1, damageType: 0x3, equippedLocation: 0x00100000) with
{
WeaponType = weaponType,
AttackType = attackType,
};
var items = new List<PluginInventoryItem> { weapon };
if (dualWield)
items.Add(Weapon(2, 1, 0x1, 0x00200000));
if (shield)
items.Add(Weapon(3, 0x2, 0, 0x00200000));
var settings = new CombatSettings { UseRecklessness = false };
float actual = AutoAttackPower.Resolve(
new MonsterRuleActions
{
DamageType = (MonsterDamageType)requestedRaw,
},
settings,
new Character(),
items);
Assert.Equal(expected, actual, 2);
}
[Fact]
public void MissileAndRecklessnessUseFullThenClampToRetailRange()
{
PluginInventoryItem bow = Weapon(1, 0x100, 0x2, 0x00400000);
var settings = new CombatSettings
{
AutoAttackPower = true,
UseRecklessness = true,
};
float power = AutoAttackPower.Resolve(
new MonsterRuleActions { DamageType = MonsterDamageType.Pierce },
settings,
new Character(recklessness: true),
[bow]);
Assert.Equal(0.9f, power, 2);
}
[Fact]
public void DisabledAutoPowerKeepsConfiguredValue()
{
var settings = new CombatSettings
{
AutoAttackPower = false,
AttackPower = 0.37f,
};
Assert.Equal(
0.37f,
AutoAttackPower.Resolve(
new MonsterRuleActions(),
settings,
new Character(),
[]),
2);
}
private static PluginInventoryItem Weapon(
uint id,
uint itemType,
int damageType,
uint equippedLocation) => new(
id, 0, "Weapon", itemType, 1, 0, 0, equippedLocation,
0, 0, 0, 1, 0, 0, 0, 0, 0, 0, false, 0,
0, damageType, 0, 0, 0, 0, 0);
private sealed class Character(bool recklessness = false) : ICharacterInfo
{
public bool IsInWorld => true;
public uint ObjectId => 1;
public uint CurrentHealth => 100;
public uint MaxHealth => 100;
public uint CurrentStamina => 100;
public uint MaxStamina => 100;
public uint CurrentMana => 100;
public uint MaxMana => 100;
public IReadOnlyList<PluginSkillInfo> Skills => recklessness
? [new(50, "Recklessness", PluginSkillTraining.Trained, 300)]
: [];
public IReadOnlyList<PluginAttributeInfo> Attributes => [];
public IReadOnlyList<PluginActiveEnchantment> ActiveEnchantments => [];
public bool TryGetSkill(uint skillId, out PluginSkillInfo skill)
{
if (skillId == 50 && recklessness)
{
skill = Skills[0];
return true;
}
skill = default;
return false;
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,106 @@
using AcDream.Plugin.Abstractions;
namespace AcDream.Plugins.MossTank.Tests;
public sealed class CombatFailureTrackerTests
{
[Fact]
public void HealthUpdateCancelsAccumulatedSuccessfulMisses()
{
var tracker = new CombatFailureTracker();
var settings = new CombatSettings
{
BlacklistMonsterAttemptCount = 2,
BlacklistMonsterTimeoutSeconds = 30,
};
tracker.ObserveTargets([Target(1)], 0, settings);
tracker.BeginAttack(10, 1);
tracker.RecordSuccessfulAttack(10, 1, settings);
tracker.ObserveTargets([Target(2)], 2, settings);
tracker.BeginAttack(10, 2);
tracker.RecordSuccessfulAttack(10, 3, settings);
Assert.Equal(
CombatSuppressionReason.None,
tracker.Reason(10, 3));
}
[Fact]
public void RepeatedSuccessfulMissesBlacklistUntilTimeout()
{
var tracker = new CombatFailureTracker();
var settings = new CombatSettings
{
BlacklistMonsterAttemptCount = 2,
BlacklistMonsterTimeoutSeconds = 20,
};
tracker.ObserveTargets([Target(1)], 0, settings);
tracker.BeginAttack(10, 1);
tracker.RecordSuccessfulAttack(10, 1, settings);
tracker.BeginAttack(10, 1);
tracker.RecordSuccessfulAttack(10, 2, settings);
Assert.Equal(
CombatSuppressionReason.Blacklisted,
tracker.Reason(10, 10));
tracker.ObserveTargets([Target(1)], 23, settings);
Assert.Equal(
CombatSuppressionReason.None,
tracker.Reason(10, 23));
}
[Fact]
public void FailedSpellStartsMarkPersistentGhost()
{
var tracker = new CombatFailureTracker();
var settings = new CombatSettings
{
DeleteGhostMonsters = true,
GhostMonsterSpellAttemptCount = 2,
};
tracker.ObserveTargets([Target(0)], 0, settings);
tracker.RecordSpellDidNotStart(10, settings);
Assert.Equal(CombatSuppressionReason.None, tracker.Reason(10, 0));
tracker.RecordSpellDidNotStart(10, settings);
Assert.Equal(CombatSuppressionReason.Ghost, tracker.Reason(10, 100));
}
[Fact]
public void HealthAgeDetectorRequiresEngagementAndConfiguredDelay()
{
var tracker = new CombatFailureTracker();
var settings = new CombatSettings
{
DeleteGhostMonstersByHealthTracker = true,
GhostDeleteHealthTrackerSeconds = 10,
};
PluginCombatTarget stale = Target(1) with
{
SecondsSinceHealthUpdate = 50,
};
tracker.ObserveTargets([stale], 0, settings);
Assert.Equal(CombatSuppressionReason.None, tracker.Reason(10, 0));
tracker.BeginEngagement(10, 0);
tracker.ObserveTargets([stale], 9.9, settings);
Assert.Equal(CombatSuppressionReason.None, tracker.Reason(10, 9.9));
tracker.ObserveTargets([stale], 10, settings);
Assert.Equal(CombatSuppressionReason.Ghost, tracker.Reason(10, 10));
}
private static PluginCombatTarget Target(long healthRevision) => new(
10,
"Drudge",
100,
5,
0,
true,
1f)
{
HealthRevision = healthRevision,
SecondsSinceHealthUpdate = 0,
};
}

View file

@ -0,0 +1,182 @@
using AcDream.Plugin.Abstractions;
namespace AcDream.Plugins.MossTank.Tests;
public sealed class CombatItemDebuffPlannerTests
{
[Fact]
public void SpellLevelAndSkillMethodsUseOfficialComparisonOrder()
{
PluginSpellInfo learned = Spell(1, "Imperil Other VI", 300, 31);
PluginSpellInfo lensSpell = Spell(2, "Imperil Other VII", 350, 31);
var catalog = new Catalog([learned], [learned, lensSpell]);
var character = new Character((31u, 500u));
PluginInventoryItem lens = Item(100, "Imperil Lens", 0x8000, 2) with
{
ItemSpellcraft = 100,
};
var settings = new CombatSettings
{
DebuffSelectionMethod = DebuffSelectionMethod.SpellLevel,
};
settings.CombatItemObjectIds.Add(lens.ObjectId);
MonsterRuleActions actions = Imperil();
CombatDebuffSource byLevel = Assert.Single(
CombatItemDebuffPlanner.Candidates(
actions,
settings,
character,
catalog,
[lens],
static (_, _) => true).Take(1));
Assert.Equal(CombatDebuffSourceKind.CasterItem, byLevel.Kind);
settings.DebuffSelectionMethod = DebuffSelectionMethod.Skill;
CombatDebuffSource bySkill = Assert.Single(
CombatItemDebuffPlanner.Candidates(
actions,
settings,
character,
catalog,
[lens],
static (_, _) => true).Take(1));
Assert.Equal(CombatDebuffSourceKind.LearnedSpell, bySkill.Kind);
}
[Fact]
public void ItemAndGrenadeSourcesRequireTheirExactProfiles()
{
PluginSpellInfo imperil = Spell(1323, "Imperil Other I", 100, 31);
var catalog = new Catalog([], [imperil]);
var character = new Character((38u, 400u));
PluginInventoryItem grenade = Item(
200,
"Iron Phial of Imperil",
0x100,
0) with { CombatUse = 0 };
var settings = new CombatSettings();
Assert.Empty(CombatItemDebuffPlanner.Candidates(
Imperil(), settings, character, catalog, [grenade],
static (_, _) => true));
settings.ConsumableNames.Add("Iron Phial of Imperil");
CombatDebuffSource source = Assert.Single(
CombatItemDebuffPlanner.Candidates(
Imperil(), settings, character, catalog, [grenade],
static (_, _) => true));
Assert.Equal(CombatDebuffSourceKind.Grenade, source.Kind);
Assert.Equal(200u, source.ItemObjectId);
Assert.Equal(100, source.SourceSkill);
}
[Fact]
public void ProcWeaponUsesFirstQualifyingAppraisedNonWarSpell()
{
PluginSpellInfo war = Spell(10, "Flame Bolt VII", 400, 34);
PluginSpellInfo imperil = Spell(11, "Imperil Other VII", 350, 31);
var catalog = new Catalog([], [war, imperil]);
PluginInventoryItem weapon = Item(300, "Debuff Sword", 1, 0) with
{
ItemSpellcraft = 360,
AppraisedSpellIds = [10u, 11u],
};
var settings = new CombatSettings();
settings.CombatItemObjectIds.Add(300u);
CombatDebuffSource source = Assert.Single(
CombatItemDebuffPlanner.Candidates(
Imperil(), settings, new Character(), catalog, [weapon],
static (_, _) => true));
Assert.Equal(CombatDebuffSourceKind.ProcWeapon, source.Kind);
Assert.Equal(11u, source.Spell.SpellId);
}
private static MonsterRuleActions Imperil() => new()
{
Flags = MonsterActionFlags.Imperil,
};
private static PluginSpellInfo Spell(
uint id,
string name,
int quality,
uint school) => new(
id, name, 1, 7, quality, 10, 60, school, string.Empty,
false, false)
{
IsDebuff = true,
IsOffensive = true,
TargetMask = 0x10,
};
private static PluginInventoryItem Item(
uint id,
string name,
uint itemType,
uint spellId) => new(
id, 0, name, itemType, 1, 0, 0, 0, 0, 0, 0,
1, 0, 0, spellId, 0, 0, 0, false, 0, 0, 0, 0, 0,
0, 0, 0);
private sealed class Catalog(
IReadOnlyList<PluginSpellInfo> known,
IReadOnlyList<PluginSpellInfo> all) : ISpellCatalog
{
public IReadOnlyList<PluginSpellInfo> KnownSelfBuffs => [];
public IReadOnlyList<PluginSpellInfo> KnownCombatSpells => known;
public bool TryGet(uint spellId, out PluginSpellInfo info)
{
foreach (PluginSpellInfo spell in all)
{
if (spell.SpellId == spellId)
{
info = spell;
return true;
}
}
info = default;
return false;
}
}
private sealed class Character(params (uint Id, uint Current)[] skills)
: ICharacterInfo
{
public bool IsInWorld => true;
public uint ObjectId => 1;
public uint CurrentHealth => 100;
public uint MaxHealth => 100;
public uint CurrentStamina => 100;
public uint MaxStamina => 100;
public uint CurrentMana => 100;
public uint MaxMana => 100;
public IReadOnlyList<PluginSkillInfo> Skills => skills.Select(
skill => new PluginSkillInfo(
skill.Id,
string.Empty,
PluginSkillTraining.Trained,
skill.Current)).ToArray();
public IReadOnlyList<PluginAttributeInfo> Attributes => [];
public IReadOnlyList<PluginActiveEnchantment> ActiveEnchantments => [];
public bool TryGetSkill(uint skillId, out PluginSkillInfo skill)
{
foreach ((uint id, uint current) in skills)
{
if (id == skillId)
{
skill = new PluginSkillInfo(
id,
string.Empty,
PluginSkillTraining.Trained,
current);
return true;
}
}
skill = default;
return false;
}
}
}

View file

@ -0,0 +1,393 @@
using AcDream.Plugin.Abstractions;
namespace AcDream.Plugins.MossTank.Tests;
public sealed class CraftingTests
{
[Fact]
public void EmbeddedDatabaseContainsAllOfficialVtankCraftInteractions()
{
Assert.Equal(757, VtankCraftDatabase.Recipes.Count);
VtankCraftRecipe recipe = Assert.Single(
VtankCraftDatabase.ForResult("Plentiful Healing Kit"));
Assert.Equal("Soft Bandages", recipe.FirstItem);
Assert.Equal("Combined Hyssop and Mandrake", recipe.SecondItem);
Assert.Equal(21u, recipe.RequiredSkill);
Assert.Equal(157, recipe.Id);
}
[Fact]
public void PlannerRecursivelyCraftsMissingPrerequisiteBeforeDesiredItem()
{
var character = new Character(trainedSkill: 21u);
IReadOnlyList<PluginInventoryItem> inventory =
[
Item(1, "Soft Bandages"),
Item(2, "Treated Mandrake"),
Item(3, "Treated Hyssop"),
];
CraftingPlan first = Assert.IsType<CraftingPlan>(CraftingPlanner.Plan(
inventory,
["Plentiful Healing Kit"],
character));
Assert.Equal("Combined Hyssop and Mandrake", first.Recipe.ResultItem);
Assert.Equal(2u, first.FirstObjectId);
Assert.Equal(3u, first.SecondObjectId);
CraftingPlan final = Assert.IsType<CraftingPlan>(CraftingPlanner.Plan(
[inventory[0], Item(4, "Combined Hyssop and Mandrake")],
["Plentiful Healing Kit"],
character));
Assert.Equal("Plentiful Healing Kit", final.Recipe.ResultItem);
Assert.Equal(1u, final.FirstObjectId);
Assert.Equal(4u, final.SecondObjectId);
}
[Fact]
public void PlannerRejectsRecipesForUntrainedRequiredSkill()
{
CraftingPlan? plan = CraftingPlanner.Plan(
[Item(1, "Soft Bandages"), Item(2, "Combined Hyssop and Mandrake")],
["Plentiful Healing Kit"],
new Character(trainedSkill: 0u));
Assert.Null(plan);
}
[Fact]
public void AllPeasSplitsTheFirstProfiledPeaBelowTheComponentThreshold()
{
CraftingPlan plan = Assert.IsType<CraftingPlan>(
CraftingPlanner.PlanPeaSplit(
[
Item(1, "Splitting Tool"),
Item(2, "Brimstone Pea"),
],
new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
CraftingPlanner.AllPeas,
},
minimumComponentCount: 20));
Assert.Equal("Brimstone", plan.Recipe.ResultItem);
Assert.Equal(1u, plan.FirstObjectId);
Assert.Equal(2u, plan.SecondObjectId);
}
[Fact]
public void PeaSplitStopsAtTheRequestedComponentCount()
{
PluginInventoryItem brimstone = Item(3, "Brimstone") with
{
StackSize = 20,
};
Assert.Null(CraftingPlanner.PlanPeaSplit(
[Item(1, "Splitting Tool"), Item(2, "Brimstone Pea"), brimstone],
new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
"Brimstone Pea",
},
minimumComponentCount: 20));
}
[Fact]
public void SameInputRecipeStagesAnExactOneUnitSplitBeforeApplying()
{
PluginInventoryItem oil = Item(1, "Chorizite Oil") with
{
StackSize = 2,
};
CraftingPlan plan = Assert.IsType<CraftingPlan>(CraftingPlanner.Plan(
[oil],
["Strong Chorizite Oil"],
new Character(trainedSkill: 0u)));
Assert.True(plan.RequiresSplitFirstStack);
Assert.Equal(1u, plan.FirstObjectId);
Assert.Equal(1u, plan.SplitContainerObjectId);
Assert.Equal(0u, plan.SecondObjectId);
}
[Fact]
public void ControllerWaitsForSplitReceiptAndBothPublishedStacksBeforeApplying()
{
var automation = new Automation
{
Inventory = [Item(20, "Chorizite Oil") with
{
ContainerObjectId = 1u,
StackSize = 2,
}],
};
var settings = new InventorySettings
{
AutoCraftItems = true,
SplitPeas = false,
};
var profiles = new CombatSettings();
profiles.ConsumableNames.Add("Strong Chorizite Oil");
var controller = new CraftingController(
new Host(automation),
settings,
profiles);
Assert.True(controller.Tick(0d, canAct: true));
Assert.Equal([(20u, 1u, 1u)], automation.Moves);
Assert.Empty(automation.Applies);
automation.InventoryCompletion = new PluginInventoryCompletion(
1,
PluginInventoryCommandKind.SplitToContainer,
20u,
0u);
automation.Busy = false;
Assert.True(controller.Tick(0.05d, canAct: true));
Assert.Empty(automation.Applies);
Assert.Equal("AutoCraft waiting for split inventory", controller.Status);
automation.Inventory =
[
Item(20, "Chorizite Oil") with
{
ContainerObjectId = 1u,
StackSize = 1,
},
Item(21, "Chorizite Oil") with
{
ContainerObjectId = 1u,
StackSize = 1,
},
];
Assert.True(controller.Tick(0.05d, canAct: true));
Assert.Equal([(20u, 21u)], automation.Applies);
Assert.Equal("Crafting Strong Chorizite Oil", controller.Status);
}
[Fact]
public void AmmunitionRequestCraftsEvenWhenGeneralAutoCraftIsDisabled()
{
var automation = new Automation
{
TrainedSkill = 37u,
Inventory =
[
Item(1u, "Wrapped Bundle of Deadly Fire Arrowheads"),
Item(2u, "Wrapped Bundle of Arrowshafts"),
],
};
var controller = new CraftingController(
new Host(automation),
new InventorySettings { AutoCraftItems = false },
new CombatSettings());
Assert.True(controller.Request("Deadly Fire Arrow"));
Assert.Equal([(1u, 2u)], automation.Applies);
Assert.Equal("Crafting Deadly Fire Arrow", controller.Status);
}
[Theory]
[InlineData("Greater Stamina Kit", 0x00010000u, 0, (int)ConsumableCategory.StaminaKit)]
[InlineData("Greater Mana Kit", 0x00010000u, 0, (int)ConsumableCategory.ManaKit)]
[InlineData("Greater Healing Kit", 0x00010000u, 0, (int)ConsumableCategory.HealthKit)]
[InlineData("Mana Food", 0u, 6, (int)ConsumableCategory.ManaFood)]
[InlineData("Stamina Food", 0u, 4, (int)ConsumableCategory.StaminaFood)]
[InlineData("Health Food", 0u, 2, (int)ConsumableCategory.HealthFood)]
[InlineData("Intricate Lockpick", 0x00020000u, 0, (int)ConsumableCategory.Lockpick)]
public void ConsumableClassificationUsesRetailPropertiesAndExactKitExceptions(
string name,
uint publicFlags,
int boosterVital,
int expected)
{
PluginInventoryItem item = Item(1u, name) with
{
PublicFlags = publicFlags,
BoosterVital = boosterVital,
};
Assert.Equal((ConsumableCategory)expected, ConsumableClassifier.Classify(item));
}
[Fact]
public void IdleCraftingRestocksTheConfiguredConsumableCategoryCount()
{
var automation = new Automation
{
TrainedSkill = 21u,
Inventory =
[
Item(1u, "Soft Bandages"),
Item(2u, "Combined Hyssop and Mandrake"),
Item(3u, "Plentiful Healing Kit"),
],
};
var settings = new InventorySettings
{
AutoCraftItems = true,
SplitPeas = false,
IdleHealthKitCount = 2,
};
var profiles = new CombatSettings();
profiles.ConsumableNames.Add("Plentiful Healing Kit");
profiles.ConsumableCategories["Plentiful Healing Kit"] =
ConsumableCategory.HealthKit;
var controller = new CraftingController(
new Host(automation),
settings,
profiles);
Assert.True(controller.TickIdle(0d, canAct: true));
Assert.Equal([(1u, 2u)], automation.Applies);
Assert.Equal("Crafting Plentiful Healing Kit", controller.Status);
}
private static PluginInventoryItem Item(uint id, string name) => new(
id, 0u, name, 0x80u, 1u, 0u, 0u, 0u, 0u, 0u, 0u,
1, 0, 0, 0u, 0, 0, 0u, false, 0d, 0, 0, 0, 0d, 0, 0, 0);
private sealed class Character(uint trainedSkill) : ICharacterInfo
{
public bool IsInWorld => true;
public uint ObjectId => 1u;
public uint CurrentHealth => 0;
public uint MaxHealth => 0;
public uint CurrentStamina => 0;
public uint MaxStamina => 0;
public uint CurrentMana => 0;
public uint MaxMana => 0;
public IReadOnlyList<PluginSkillInfo> Skills => trainedSkill == 0u
? []
: [new(trainedSkill, "Craft", PluginSkillTraining.Trained, 300)];
public IReadOnlyList<PluginAttributeInfo> Attributes => [];
public IReadOnlyList<PluginActiveEnchantment> ActiveEnchantments => [];
public bool TryGetSkill(uint skillId, out PluginSkillInfo skill)
{
if (skillId == trainedSkill && trainedSkill != 0u)
{
skill = Skills[0];
return true;
}
skill = default;
return false;
}
}
private sealed class Automation
: IAutomationSurface, ICharacterInfo, IItemAutomation
{
public bool IsAvailable => true;
public ICharacterInfo Character => this;
public ISpellCatalog Spells => NoOpAutomationSurface.Instance;
public IMagicCommands Magic => NoOpAutomationSurface.Instance;
public IPluginChat Chat => NoOpAutomationSurface.Instance;
public IItemAutomation Items => this;
public bool IsInWorld => true;
public uint ObjectId => 1u;
public uint CurrentHealth => 0u;
public uint MaxHealth => 0u;
public uint CurrentStamina => 0u;
public uint MaxStamina => 0u;
public uint CurrentMana => 0u;
public uint MaxMana => 0u;
public uint TrainedSkill { get; set; }
public IReadOnlyList<PluginSkillInfo> Skills => TrainedSkill == 0u
? []
: [new(TrainedSkill, "Craft", PluginSkillTraining.Trained, 300)];
public IReadOnlyList<PluginAttributeInfo> Attributes => [];
public IReadOnlyList<PluginActiveEnchantment> ActiveEnchantments => [];
public bool IsBusy => Busy;
public bool Busy { get; set; }
public PluginInventoryCompletion InventoryCompletion { get; set; }
public PluginInventoryCompletion LastInventoryCompletion =>
InventoryCompletion;
public IReadOnlyList<PluginInventoryItem> Inventory { get; set; } = [];
public List<(uint Source, uint Container, uint Amount)> Moves { get; } = [];
public List<(uint Source, uint Target)> Applies { get; } = [];
public IReadOnlyList<PluginInventoryItem> CaptureOwnedItems() => Inventory;
public PluginItemCommandResult MoveToContainer(
uint objectId,
uint containerObjectId,
uint amount = 0u,
int placement = 0)
{
Moves.Add((objectId, containerObjectId, amount));
Busy = true;
return new(PluginItemCommandStatus.Started);
}
public PluginItemCommandResult Apply(uint objectId, uint targetObjectId)
{
Applies.Add((objectId, targetObjectId));
Busy = true;
return new(PluginItemCommandStatus.Started);
}
public bool TryGetSkill(uint skillId, out PluginSkillInfo skill)
{
if (skillId == TrainedSkill && TrainedSkill != 0u)
{
skill = Skills[0];
return true;
}
skill = default;
return false;
}
}
private sealed class Host(IAutomationSurface automation) : IPluginHost
{
public bool HasUi => false;
public IPluginLogger Log => Logger.Instance;
public IGameState State => EmptyState.Instance;
public IEvents Events => EmptyEvents.Instance;
public ISelectionService Selection => EmptySelection.Instance;
public IUiRegistry Ui => NoOpUiRegistry.Instance;
public IAutomationSurface Automation { get; } = automation;
}
private sealed class Logger : IPluginLogger
{
public static Logger Instance { get; } = new();
public void Info(string message) { }
public void Warn(string message) { }
public void Error(string message, Exception? exception = null) { }
}
private sealed class EmptyState : IGameState
{
public static EmptyState Instance { get; } = new();
public IReadOnlyList<WorldEntitySnapshot> Entities => [];
}
private sealed class EmptyEvents : IEvents
{
public static EmptyEvents Instance { get; } = new();
public event Action<WorldEntitySnapshot> EntitySpawned
{
add { }
remove { }
}
public event Action<double> Tick
{
add { }
remove { }
}
}
private sealed class EmptySelection : ISelectionService
{
public static EmptySelection Instance { get; } = new();
public uint? SelectedObjectId => null;
public uint? PreviousObjectId => null;
public event Action<SelectionChangedEvent> Changed
{
add { }
remove { }
}
public bool Select(uint objectId) => false;
public bool Clear() => false;
}
}

View file

@ -0,0 +1,219 @@
using AcDream.Plugin.Abstractions;
namespace AcDream.Plugins.MossTank.Tests;
public sealed class DebuffSchedulerTests
{
[Theory]
[InlineData("Fester Other VII", 1 << 0, 0)]
[InlineData("Broadside of a Barn", 1 << 1, 0)]
[InlineData("Gravity Well", 1 << 2, 0)]
[InlineData("Incantation of Imperil Other", 1 << 3, 0)]
[InlineData("Magic Yield Other VI", 1 << 4, 0)]
[InlineData("Incantation of Fire Vulnerability Other", 1 << 5, 5)]
[InlineData("Acid Lure VI", 1 << 5, 6)]
[InlineData("Incantation of Blade Lure", 1 << 5, 1)]
[InlineData("Incantation of Bludgeon Lure", 1 << 5, 3)]
[InlineData("Incantation of Frost Lure", 1 << 5, 4)]
[InlineData("Incantation of Flame Lure", 1 << 5, 5)]
[InlineData("Incantation of Lightning Lure", 1 << 5, 7)]
[InlineData("Incantation of Piercing Lure", 1 << 5, 2)]
[InlineData("Weakening Curse VII", 1 << 9, 0)]
[InlineData("Festering Curse VII", 1 << 10, 0)]
[InlineData("Corruption VII", 1 << 11, 0)]
[InlineData("Destructive Curse VII", 1 << 12, 0)]
[InlineData("Corrosion VII", 1 << 13, 0)]
public void ClassifierMapsEveryVtankMonsterDebuffColumn(
string name,
int expectedFlag,
int expectedDamage)
{
Assert.True(DebuffSpellCatalog.TryClassify(
Spell(1, name),
out DebuffIdentity identity,
out _));
Assert.Equal((MonsterActionFlags)expectedFlag, identity.Flag);
Assert.Equal((MonsterDamageType)expectedDamage, identity.DamageType);
}
[Fact]
public void DamageAndExtraVulnerabilityBothProduceCandidates()
{
DebuffSpellCatalog catalog = DebuffSpellCatalog.Build(
[
Spell(1, "Incantation of Fire Vulnerability Other"),
Spell(2, "Incantation of Acid Vulnerability Other"),
]);
var actions = new MonsterRuleActions
{
Flags = MonsterActionFlags.Vulnerability,
DamageType = MonsterDamageType.Fire,
ExtraVulnerability = MonsterDamageType.Acid,
};
IReadOnlyList<DebuffChoice> choices = catalog.Candidates(
actions,
DebuffSelectionMethod.SpellLevel,
new Character(),
static (_, _) => true);
Assert.Equal(2, choices.Count);
Assert.Contains(choices, choice =>
choice.Identity.DamageType == MonsterDamageType.Fire);
Assert.Contains(choices, choice =>
choice.Identity.DamageType == MonsterDamageType.Acid);
}
[Fact]
public void LureBladeItemSpellIsNotAClassicVulnerabilityLure()
{
Assert.False(DebuffSpellCatalog.TryClassify(
Spell(1, "Incantation of Lure Blade"),
out _,
out _));
}
[Fact]
public void SkillPreferenceUsesBuffedSchoolSkillBeforeSpellTier()
{
DebuffSpellCatalog catalog = DebuffSpellCatalog.Build(
[
Spell(1, "Imperil Other VII", tier: 7, school: 33),
Spell(2, "Weakening Curse VI", tier: 6, school: 43),
]);
var actions = new MonsterRuleActions
{
Flags = MonsterActionFlags.Imperil | MonsterActionFlags.WeakeningCurse,
};
var character = new Character(
[
new PluginSkillInfo(33, "Life Magic", PluginSkillTraining.Trained, 300),
new PluginSkillInfo(43, "Void Magic", PluginSkillTraining.Trained, 420),
]);
IReadOnlyList<DebuffChoice> choices = catalog.Candidates(
actions,
DebuffSelectionMethod.Skill,
character,
static (_, _) => true);
Assert.Equal(2u, choices[0].Spell.SpellId);
}
[Fact]
public void TrackerWaitsForMatchingSuccessfulServerReceipt()
{
var tracker = new DebuffTracker();
var identity = new DebuffIdentity(
MonsterActionFlags.Imperil,
MonsterDamageType.Auto);
PluginSpellInfo spell = Spell(10, "Imperil Other VII", duration: 60);
tracker.Begin(99, identity, spell, now: 10, completionRevision: 4);
Assert.False(tracker.Observe(
new PluginCastCompletion(5, 11, 99, 0), 12).Completed);
Assert.True(tracker.HasPending);
DebuffCompletion failed = tracker.Observe(
new PluginCastCompletion(6, 10, 99, 0x0402), 13);
Assert.True(failed.Completed);
Assert.False(failed.Succeeded);
Assert.True(tracker.IsDue(99, identity, spell, 13, 5));
tracker.Begin(99, identity, spell, 14, completionRevision: 6);
DebuffCompletion succeeded = tracker.Observe(
new PluginCastCompletion(7, 10, 99, 0), 15);
Assert.True(succeeded.Succeeded);
Assert.False(tracker.IsDue(99, identity, spell, 69, 5));
Assert.True(tracker.IsDue(99, identity, spell, 70, 5));
}
[Fact]
public void DamageOverTimeDoesNotUsePrecastWindow()
{
var tracker = new DebuffTracker();
var identity = new DebuffIdentity(
MonsterActionFlags.Corrosion,
MonsterDamageType.Auto);
PluginSpellInfo spell = Spell(
10,
"Corrosion VII",
duration: 60) with { IsDamageOverTime = true };
tracker.Begin(99, identity, spell, 0, 0);
tracker.Observe(new PluginCastCompletion(1, 10, 99, 0), 1);
Assert.False(tracker.IsDue(99, identity, spell, 60, 20));
Assert.True(tracker.IsDue(99, identity, spell, 61, 20));
}
[Fact]
public void FakeImperilCreatesVtankThreeThousandSecondLocalMarker()
{
var tracker = new DebuffTracker();
var identity = new DebuffIdentity(
MonsterActionFlags.Imperil,
MonsterDamageType.Auto);
PluginSpellInfo learned = Spell(
10,
"Imperil Other VII",
tier: 7,
duration: 60);
tracker.RecordFakeImperil(99u, now: 10d);
Assert.False(tracker.IsDue(99u, identity, learned, 3009d, 0d));
Assert.True(tracker.IsDue(99u, identity, learned, 3010d, 0d));
}
private static PluginSpellInfo Spell(
uint id,
string name,
int tier = 8,
uint school = 33,
float duration = 30) => new(
id,
name,
Family: id,
Tier: tier,
Difficulty: 300,
ManaCost: 20,
DurationSeconds: duration,
School: school,
Description: string.Empty,
IsSelfTargeted: false,
IsBeneficial: false)
{
IsDebuff = true,
IsOffensive = true,
};
private sealed class Character(
IReadOnlyList<PluginSkillInfo>? skills = null) : ICharacterInfo
{
public bool IsInWorld => true;
public uint ObjectId => 1;
public uint CurrentHealth => 100;
public uint MaxHealth => 100;
public uint CurrentStamina => 100;
public uint MaxStamina => 100;
public uint CurrentMana => 100;
public uint MaxMana => 100;
public IReadOnlyList<PluginSkillInfo> Skills { get; } = skills ?? [];
public IReadOnlyList<PluginAttributeInfo> Attributes => [];
public IReadOnlyList<PluginActiveEnchantment> ActiveEnchantments => [];
public bool TryGetSkill(uint skillId, out PluginSkillInfo skill)
{
foreach (PluginSkillInfo candidate in Skills)
{
if (candidate.SkillId == skillId)
{
skill = candidate;
return true;
}
}
skill = default;
return false;
}
}
}

View file

@ -0,0 +1,375 @@
using AcDream.Plugin.Abstractions;
namespace AcDream.Plugins.MossTank.Tests;
public sealed class DispelControllerTests
{
private const uint SelfDispel = (uint)SpellId.EradicateLifeMagicSelf;
[Fact]
public void LearnedSelfDispelWithChorizitePrecedesDispelItems()
{
var automation = new Automation
{
Active = [new PluginActiveEnchantment(100u, 7u, 7, 120d)],
SpellLookup = [Vulnerability(100u, 400), SelfDispelSpell()],
KnownSpellIds = new HashSet<uint> { SelfDispel },
Inventory = [Item(10u, "Chorizite"), Item(11u, "Rune of Dispel")],
Mode = PluginCombatMode.Magic,
};
var controller = new DispelController(
new Host(automation),
new VitalSettings
{
CastDispelSelf = true,
UseDispelItems = true,
});
Assert.True(controller.Tick(0d, canAct: true));
Assert.Equal((SelfDispel, 1u), automation.TargetedCast);
Assert.Equal(0u, automation.UsedItem);
Assert.True(controller.Tick(1d, canAct: true));
Assert.Equal("Casting Eradicate Life Magic Self", controller.Status);
automation.CastCompletion = new PluginCastCompletion(
1,
SelfDispel,
1u,
0u);
Assert.True(controller.Tick(0.05d, canAct: true));
Assert.Equal(
"Dispel completed: Eradicate Life Magic Self",
controller.Status);
}
[Fact]
public void MissingChoriziteFallsThroughToOfficialDispelItemOrder()
{
var automation = new Automation
{
Active = [new PluginActiveEnchantment(100u, 7u, 7, 120d)],
SpellLookup = [Vulnerability(100u, 375), SelfDispelSpell()],
KnownSpellIds = new HashSet<uint> { SelfDispel },
Inventory =
[
Item(20u, "Condensed Dispel Potion"),
Item(21u, "Black Market Gem of Dispelling"),
],
Mode = PluginCombatMode.Magic,
};
var controller = new DispelController(
new Host(automation),
new VitalSettings
{
CastDispelSelf = true,
UseDispelItems = true,
});
Assert.True(controller.Tick(0d, canAct: true));
// cx.cs checks the <=400 list before its <=350 list. The 375-difficulty
// vulnerability therefore selects Black Market before Condensed.
Assert.Equal(21u, automation.UsedItem);
Assert.Equal(default, automation.TargetedCast);
}
[Fact]
public void DispelItemsIgnoreVulnerabilitiesAboveTheirDifficultyLimit()
{
var automation = new Automation
{
Active = [new PluginActiveEnchantment(100u, 7u, 7, 120d)],
SpellLookup = [Vulnerability(100u, 401)],
Inventory = [Item(11u, "Rune of Dispel")],
Mode = PluginCombatMode.Magic,
};
var controller = new DispelController(
new Host(automation),
new VitalSettings { UseDispelItems = true });
Assert.False(controller.Tick(0d, canAct: true));
Assert.Equal(0u, automation.UsedItem);
Assert.Equal("Dispel idle", controller.Status);
}
[Fact]
public void AttenuatedAwakenerDispelsTheHighestScoringNearbyFellow()
{
PluginSpellInfo fire = Vulnerability(100u, 350) with
{
QualityOverride = 300,
};
PluginSpellInfo cold = new(
101u,
"Cold Vulnerability Other VII",
Family: 8u,
Tier: 7,
Difficulty: 350,
ManaCost: 0,
DurationSeconds: 300f,
School: 32u,
Description: string.Empty,
IsSelfTargeted: false,
IsBeneficial: false)
{
IsDebuff = true,
IsOffensive = true,
QualityOverride = 320,
};
var automation = new Automation
{
SpellLookup = [fire, cold],
Inventory =
[
Item(50u, "Attenuated Awakener") with
{
EquippedLocation = 1u,
},
],
Mode = PluginCombatMode.Magic,
SkillsById = new Dictionary<uint, PluginSkillInfo>
{
[31u] = new(31u, "Creature Enchantment",
PluginSkillTraining.Trained, 300u),
[14u] = new(14u, "Arcane Lore",
PluginSkillTraining.Trained, 110u),
},
Members =
[
Fellow(2u, "One vuln", 4f),
Fellow(3u, "Two vulns", 5f),
Fellow(4u, "Too far", 5.1f),
],
TrackedByTarget = new Dictionary<uint, IReadOnlyList<PluginTrackedEnchantment>>
{
[2u] = [Tracked(2u, fire)],
[3u] = [Tracked(3u, fire), Tracked(3u, cold)],
[4u] = [Tracked(4u, fire), Tracked(4u, cold)],
},
};
var controller = new DispelController(
new Host(automation),
new VitalSettings { UseDispelDrum = true });
Assert.True(controller.Tick(0d, canAct: true));
Assert.Equal((50u, 3u), automation.AppliedItem);
Assert.Equal(
"Using Attenuated Awakener on Two vulns",
controller.Status);
}
private static PluginSpellInfo Vulnerability(uint id, int difficulty) => new(
id,
"Fire Vulnerability Other VII",
Family: 7u,
Tier: 7,
Difficulty: difficulty,
ManaCost: 0,
DurationSeconds: 300f,
School: 32u,
Description: string.Empty,
IsSelfTargeted: false,
IsBeneficial: false)
{
IsDebuff = true,
IsOffensive = true,
};
private static PluginSpellInfo SelfDispelSpell() => new(
SelfDispel,
"Eradicate Life Magic Self",
Family: 8u,
Tier: 7,
Difficulty: 400,
ManaCost: 10,
DurationSeconds: 0f,
School: 33u,
Description: string.Empty,
IsSelfTargeted: true,
IsBeneficial: true);
private static PluginFellowMember Fellow(uint id, string name, float distance) =>
new(id, name, 100u, 100u, 100u, 100u, 100u, 100u, distance);
private static PluginTrackedEnchantment Tracked(
uint target,
PluginSpellInfo spell) => new(
target,
spell.SpellId,
spell.Family,
spell.Quality,
spell.IsUntargeted,
120d);
private static PluginInventoryItem Item(uint id, string name) => new(
id, 0u, name, 0x80u, 1u, 0u, 0u, 0u, 0u, 0u, 0u,
1, 0, 0, 0u, 0, 0, 0u, false, 0d, 0, 0, 0, 0d, 0, 0, 0);
private sealed class Automation
: IAutomationSurface, ICharacterInfo, ISpellCatalog, IMagicCommands,
ICombatAutomation, IItemAutomation
, IFellowshipAutomation, IEnchantmentAutomation
{
public bool IsAvailable => true;
public ICharacterInfo Character => this;
public ISpellCatalog Spells => this;
public IMagicCommands Magic => this;
public IPluginChat Chat => NoOpAutomationSurface.Instance;
public ICombatAutomation Combat => this;
public IItemAutomation Items => this;
public IFellowshipAutomation Fellowship => this;
public IEnchantmentAutomation Enchantments => this;
public bool IsInWorld => true;
public uint ObjectId => 1u;
public uint CurrentHealth => 100u;
public uint MaxHealth => 100u;
public uint CurrentStamina => 100u;
public uint MaxStamina => 100u;
public uint CurrentMana => 100u;
public uint MaxMana => 100u;
public IReadOnlyDictionary<uint, PluginSkillInfo> SkillsById { get; init; } =
new Dictionary<uint, PluginSkillInfo>();
public IReadOnlyList<PluginSkillInfo> Skills => [.. SkillsById.Values];
public IReadOnlyList<PluginAttributeInfo> Attributes => [];
public IReadOnlyList<PluginActiveEnchantment> ActiveEnchantments => Active;
public IReadOnlyList<PluginActiveEnchantment> Active { get; init; } = [];
public IReadOnlyList<PluginSpellInfo> KnownSelfBuffs => [];
public IReadOnlyList<PluginSpellInfo> SpellLookup { get; init; } = [];
public IReadOnlySet<uint> KnownSpellIds { get; init; } =
new HashSet<uint>();
public IReadOnlyList<PluginInventoryItem> Inventory { get; init; } = [];
public PluginCombatMode Mode { get; set; }
public PluginCombatSnapshot Snapshot => new(
0u, Mode, default, 0f, 0f, false, false, false, false);
public bool IsCasting => false;
public PluginCastCompletion CastCompletion { get; set; }
public PluginCastCompletion LastCompletion => CastCompletion;
public PluginItemUseCompletion ItemCompletion { get; set; }
PluginItemUseCompletion IItemAutomation.LastCompletion => ItemCompletion;
bool IItemAutomation.IsAvailable => true;
bool IItemAutomation.IsBusy => false;
public (uint Spell, uint Target) TargetedCast { get; private set; }
public uint UsedItem { get; private set; }
public (uint Source, uint Target) AppliedItem { get; private set; }
public bool IsInFellowship => Members.Count != 0;
public IReadOnlyList<PluginFellowMember> Members { get; init; } = [];
public IReadOnlyDictionary<uint, IReadOnlyList<PluginTrackedEnchantment>>
TrackedByTarget { get; init; } =
new Dictionary<uint, IReadOnlyList<PluginTrackedEnchantment>>();
public bool TryGetSkill(uint skillId, out PluginSkillInfo skill)
{
return SkillsById.TryGetValue(skillId, out skill);
}
public bool TryGet(uint spellId, out PluginSpellInfo info)
{
foreach (PluginSpellInfo spell in SpellLookup)
{
if (spell.SpellId == spellId)
{
info = spell;
return true;
}
}
info = default;
return false;
}
public bool IsKnown(uint spellId) => KnownSpellIds.Contains(spellId);
public IReadOnlyList<PluginCombatTarget> CaptureHostileTargets(
float maximumDistance) => [];
public PluginCombatCommandResult EnterDefaultMode() =>
new(PluginCombatCommandStatus.AlreadyReady);
public PluginCombatCommandResult EnterMode(PluginCombatMode mode)
{
Mode = mode;
return new(PluginCombatCommandStatus.ModeChangeSent);
}
public PluginCombatCommandResult BeginPhysicalAttack(
uint targetObjectId,
PluginAttackHeight height,
float power) => new(PluginCombatCommandStatus.Refused);
public PluginCombatCommandResult ReleasePhysicalAttack() =>
new(PluginCombatCommandStatus.Refused);
public PluginCombatCommandResult AbortPhysicalAttack() =>
new(PluginCombatCommandStatus.Stopped);
public PluginCastGate EvaluateGate(uint spellId) => PluginCastGate.Ready;
public PluginCastGate EvaluateGate(uint spellId, uint targetObjectId) =>
PluginCastGate.Ready;
public bool Cast(uint spellId) => false;
public bool Cast(uint spellId, uint targetObjectId)
{
TargetedCast = (spellId, targetObjectId);
return true;
}
public IReadOnlyList<PluginInventoryItem> CaptureOwnedItems() => Inventory;
public PluginItemCommandResult Use(uint objectId)
{
UsedItem = objectId;
return new(PluginItemCommandStatus.Started);
}
public PluginItemCommandResult Apply(uint objectId, uint targetObjectId)
{
AppliedItem = (objectId, targetObjectId);
return new(PluginItemCommandStatus.Started);
}
public IReadOnlyList<PluginFellowMember> CaptureMembers() => Members;
public IReadOnlyList<PluginTrackedEnchantment> Capture(uint targetObjectId) =>
TrackedByTarget.TryGetValue(targetObjectId, out var tracked)
? tracked
: [];
}
private sealed class Host(Automation automation) : IPluginHost
{
public bool HasUi => false;
public IPluginLogger Log => Logger.Instance;
public IGameState State => EmptyState.Instance;
public IEvents Events => EmptyEvents.Instance;
public ISelectionService Selection => EmptySelection.Instance;
public IUiRegistry Ui => NoOpUiRegistry.Instance;
public IAutomationSurface Automation { get; } = automation;
}
private sealed class Logger : IPluginLogger
{
public static Logger Instance { get; } = new();
public void Info(string message) { }
public void Warn(string message) { }
public void Error(string message, Exception? exception = null) { }
}
private sealed class EmptyState : IGameState
{
public static EmptyState Instance { get; } = new();
public IReadOnlyList<WorldEntitySnapshot> Entities => [];
}
private sealed class EmptyEvents : IEvents
{
public static EmptyEvents Instance { get; } = new();
public event Action<WorldEntitySnapshot> EntitySpawned
{
add { }
remove { }
}
public event Action<double> Tick
{
add { }
remove { }
}
}
private sealed class EmptySelection : ISelectionService
{
public static EmptySelection Instance { get; } = new();
public uint? SelectedObjectId => null;
public uint? PreviousObjectId => null;
public event Action<SelectionChangedEvent> Changed
{
add { }
remove { }
}
public bool Select(uint objectId) => false;
public bool Clear() => false;
}
}

View file

@ -0,0 +1,194 @@
using AcDream.Plugins.MossTank.Expressions;
namespace AcDream.Plugins.MossTank.Tests;
public sealed class ExpressionEngineTests
{
[Theory]
[InlineData("1+2*3", 7d)]
[InlineData("(1+2)*3", 9d)]
[InlineData("2^3^2", 512d)]
[InlineData("0x10+1", 17d)]
[InlineData("~1", -2d)]
[InlineData("16>>2", 4d)]
public void ArithmeticUsesUtilityBeltPrecedence(string source, double expected)
{
Assert.Equal(expected, Evaluate(source).AsNumber(), 10);
}
[Fact]
public void StatementsAndAllVariableScopesReturnLastValue()
{
var state = new ExpressionState();
ExpressionValue result = Evaluate(
"$session=2;@persistent=3;&global=4;"
+ "setvar[dynamic,5];$session+@persistent+&global+$dynamic",
state);
Assert.Equal(14d, result.AsNumber());
Assert.Equal(2d, state.Get(ExpressionVariableScope.Session, "session").AsNumber());
Assert.Equal(3d, state.Get(ExpressionVariableScope.Persistent, "persistent").AsNumber());
Assert.Equal(4d, state.Get(ExpressionVariableScope.Global, "global").AsNumber());
}
[Fact]
public void NumericAndComputedVariableNamesAreSupported()
{
var state = new ExpressionState();
ExpressionValue result = Evaluate(
"$1=7;$name=`chosen`;$getvar[name]=9;$1+$chosen",
state);
Assert.Equal(16d, result.AsNumber());
}
[Fact]
public void BooleanOperatorsShortCircuitAndReturnUtilityBeltValues()
{
ExpressionFunctionRegistry functions = CoreExpressionFunctions.CreateDefault();
int calls = 0;
functions.Register("boom", 0, 0, (_, _) =>
{
calls++;
throw new InvalidOperationException("must not run");
});
var context = new ExpressionEvaluationContext(new ExpressionState(), functions);
Assert.Equal(0d, ExpressionProgram.Compile("0&&boom[]").Evaluate(context).AsNumber());
Assert.Equal(3d, ExpressionProgram.Compile("3||boom[]").Evaluate(context).AsNumber());
Assert.Equal(0, calls);
}
[Fact]
public void StringsAreCaseInsensitiveAndPreserveDashedBareText()
{
Assert.True(Evaluate("`Olthoi`==`olthoi`").IsTruthy);
Assert.Equal("Olthoi-Noble", Evaluate("Olthoi-Noble").AsString());
Assert.Equal("Olthoi Noble", Evaluate("`Olthoi `+Noble").AsString());
}
[Fact]
public void RegexOperatorPublishesCaptureGroups()
{
var state = new ExpressionState();
ExpressionValue result = Evaluate(
"`Olthoi 275`#`(?<level>[0-9]+)`;$capturegroup_level",
state);
Assert.Equal("275", result.AsString());
}
[Fact]
public void ListsSupportMutationIndexSlicesAndHigherOrderFunctions()
{
var state = new ExpressionState();
Evaluate("$0=old0;$1=old1;$2=old2", state);
Assert.Equal(4d, Evaluate(
"$items=listcreate[1,2,3];listadd[$items,4];listcount[$items]",
state).AsNumber());
Assert.Equal(4d, Evaluate("$items{-1}", state).AsNumber());
Assert.Equal("[2,3]", Evaluate("$items{1:3}", state).ToDisplayString());
Assert.Equal("[2,4,6,8]", Evaluate(
"listmap[$items,`$1*2`]", state).ToDisplayString());
Assert.Equal("[1,3]", Evaluate(
"listfilter[$items,`$1%2==1`]", state).ToDisplayString());
Assert.Equal(10d, Evaluate(
"listreduce[$items,`$2+$1`]", state).AsNumber());
Assert.Equal("[4,3,2,1]", Evaluate(
"listsort[$items,`$2-$1`]", state).ToDisplayString());
Assert.Equal("old0", state.Get(ExpressionVariableScope.Session, "0").AsString());
Assert.Equal("old1", state.Get(ExpressionVariableScope.Session, "1").AsString());
Assert.Equal("old2", state.Get(ExpressionVariableScope.Session, "2").AsString());
}
[Fact]
public void ListRangeSupportsBothDirections()
{
Assert.Equal("[1,2,3]", Evaluate("listfromrange[1,3]").ToDisplayString());
Assert.Equal("[3,2,1]", Evaluate("listfromrange[3,1]").ToDisplayString());
}
[Fact]
public void DictionariesSupportMutationAndShallowCopy()
{
var state = new ExpressionState();
Assert.Equal(2d, Evaluate(
"$dict=dictcreate[a,1,b,2];$dict{b}", state).AsNumber());
Assert.False(Evaluate("dictadditem[$dict,c,3]", state).IsTruthy);
Assert.True(Evaluate("dictadditem[$dict,c,4]", state).IsTruthy);
Assert.Equal(3d, Evaluate("dictsize[$dict]", state).AsNumber());
Assert.Equal("[a,b,c]", Evaluate("dictkeys[$dict]", state).ToDisplayString());
Assert.Equal(4d, Evaluate("dictgetitem[dictcopy[$dict],c]", state).AsNumber());
Assert.True(Evaluate("dictremovekey[$dict,b]", state).IsTruthy);
}
[Fact]
public void CollectionsRejectDirectAndIndirectCycles()
{
var state = new ExpressionState();
Evaluate("$first=listcreate[];$second=listcreate[$first]", state);
ExpressionEvaluationException direct = Assert.Throws<ExpressionEvaluationException>(
() => Evaluate("listadd[$first,$first]", state));
ExpressionEvaluationException indirect = Assert.Throws<ExpressionEvaluationException>(
() => Evaluate("listadd[$first,$second]", state));
Assert.Contains("cyclic", direct.Message, StringComparison.OrdinalIgnoreCase);
Assert.Contains("cyclic", indirect.Message, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void CoordinatesUseAcCompassScaleAndMeters()
{
Assert.Equal(-12.5d, Evaluate(
"coordinategetns[coordinateparse[`12.5S, 3.0E`]]").AsNumber());
Assert.Equal(24d, Evaluate(
"coordinatedistanceflat[coordinateparse[`0N, 0E`],"
+ "coordinateparse[`0.1N, 0E`]]").AsNumber(), 8);
Assert.Equal("12.5S, 3.0E", Evaluate(
"coordinatetostring[coordinateparse[`12.5S, 3.0E`]]").AsString());
}
[Fact]
public void InstructionBudgetAndCancellationBoundNestedEvaluation()
{
var functions = CoreExpressionFunctions.CreateDefault();
var budgeted = new ExpressionEvaluationContext(
new ExpressionState(), functions, instructionBudget: 25);
Assert.Throws<ExpressionEvaluationException>(() =>
ExpressionProgram.Compile(
"listmap[listfromrange[1,100],`$1*2`]").Evaluate(budgeted));
using var cancellation = new CancellationTokenSource();
cancellation.Cancel();
var cancelled = new ExpressionEvaluationContext(
new ExpressionState(), functions, cancellationToken: cancellation.Token);
Assert.Throws<OperationCanceledException>(() =>
ExpressionProgram.Compile("1+1").Evaluate(cancelled));
}
[Fact]
public void FunctionErrorsNameTheSignatureAndOffset()
{
ExpressionEvaluationException error = Assert.Throws<ExpressionEvaluationException>(
() => Evaluate("sqrt[1,2]"));
Assert.Contains("sqrt[number]", error.Message, StringComparison.Ordinal);
Assert.Contains("offset 0", error.Message, StringComparison.Ordinal);
}
private static ExpressionValue Evaluate(
string source,
ExpressionState? state = null)
{
var context = new ExpressionEvaluationContext(
state ?? new ExpressionState(),
CoreExpressionFunctions.CreateDefault(new Random(1234)));
return ExpressionProgram.Compile(source).Evaluate(context);
}
}

View file

@ -0,0 +1,201 @@
using AcDream.Plugin.Abstractions;
namespace AcDream.Plugins.MossTank.Tests;
public sealed class FellowshipManagerTests
{
[Fact]
public void XpTellQueuesAndRecruitsANearbyPlayerThroughTheHostCommand()
{
var automation = new FakeAutomation();
automation.AddTell(2u, "Alice", "xp");
var manager = new FellowshipManager(new FakeHost(automation));
manager.Tick(0.1d, enabled: true);
Assert.Equal(2u, automation.RecruitedObjectId);
Assert.Contains(automation.Submitted, value => value.Contains(
"I will recruit you in a moment", StringComparison.Ordinal));
Assert.Contains("Alice", manager.WaitingNames);
automation.Roster.Add(Member(2u, "Alice"));
manager.Tick(0.1d, enabled: true);
Assert.DoesNotContain("Alice", manager.WaitingNames);
}
[Fact]
public void MemberVoteExecutesGiveLeaderAfterTheOfficialTwoMinuteWindow()
{
var automation = new FakeAutomation();
automation.Roster.Add(Member(2u, "Alice"));
automation.Roster.Add(Member(3u, "Bob"));
var manager = new FellowshipManager(new FakeHost(automation));
automation.AddTell(2u, "Alice", "startvote giveleader Bob");
manager.Tick(0.1d, enabled: true);
automation.AddTell(3u, "Bob", "vote 1 yes");
manager.Tick(0.1d, enabled: true);
manager.Tick(120d, enabled: true);
Assert.Equal(3u, automation.AssignedLeaderObjectId);
Assert.Contains(automation.Submitted, value => value.Contains(
"passed (2/0)", StringComparison.Ordinal));
}
private static PluginFellowMember Member(uint id, string name) => new(
id, name, 100u, 100u, 100u, 100u, 100u, 100u, 0f);
private sealed class FakeHost(FakeAutomation automation) : IPluginHost
{
public bool HasUi => false;
public IPluginLogger Log { get; } = new StubLogger();
public IGameState State { get; } = new StubState();
public IEvents Events { get; } = new StubEvents();
public ISelectionService Selection { get; } = new StubSelection();
public IUiRegistry Ui => NoOpUiRegistry.Instance;
public IAutomationSurface Automation { get; } = automation;
}
private sealed class StubLogger : IPluginLogger
{
public void Info(string message) { }
public void Warn(string message) { }
public void Error(string message, Exception? exception = null) { }
}
private sealed class StubState : IGameState
{
public IReadOnlyList<WorldEntitySnapshot> Entities => [];
}
private sealed class StubEvents : IEvents
{
public event Action<WorldEntitySnapshot> EntitySpawned
{
add { }
remove { }
}
public event Action<double> Tick
{
add { }
remove { }
}
}
private sealed class StubSelection : ISelectionService
{
public uint? SelectedObjectId => null;
public uint? PreviousObjectId => null;
public event Action<SelectionChangedEvent> Changed
{
add { }
remove { }
}
public bool Select(uint objectId) => false;
public bool Clear() => false;
}
private sealed class FakeAutomation
: IAutomationSurface, ICharacterInfo, IPluginChat,
IFellowshipAutomation, INavigationAutomation
{
private ulong _sequence;
private readonly List<PluginChatMessage> _messages = [];
public bool IsAvailable => true;
public ICharacterInfo Character => this;
public ISpellCatalog Spells => NoOpAutomationSurface.Instance;
public IMagicCommands Magic => NoOpAutomationSurface.Instance;
public IPluginChat Chat => this;
public IFellowshipAutomation Fellowship => this;
public INavigationAutomation Navigation => this;
public bool IsInWorld => true;
public string Name => "Leader";
public uint ObjectId => 1u;
public uint CurrentHealth => 100u;
public uint MaxHealth => 100u;
public uint CurrentStamina => 100u;
public uint MaxStamina => 100u;
public uint CurrentMana => 100u;
public uint MaxMana => 100u;
public IReadOnlyList<PluginSkillInfo> Skills => [];
public IReadOnlyList<PluginAttributeInfo> Attributes => [];
public IReadOnlyList<PluginActiveEnchantment> ActiveEnchantments => [];
public bool TryGetSkill(uint skillId, out PluginSkillInfo skill)
{
skill = default;
return false;
}
public bool IsInFellowship => true;
string IFellowshipAutomation.Name => "Test Fellow";
public uint LeaderObjectId => 1u;
public bool IsOpen { get; private set; } = true;
public bool IsLocked => false;
public int MemberCount => Roster.Count;
public List<PluginFellowMember> Roster { get; } = [Member(1u, "Leader")];
public IReadOnlyList<PluginFellowMember> CaptureMembers() =>
Roster.Where(static member => member.ObjectId != 1u).ToArray();
public IReadOnlyList<PluginFellowMember> CaptureRoster() => Roster.ToArray();
public uint RecruitedObjectId { get; private set; }
public uint DismissedObjectId { get; private set; }
public uint AssignedLeaderObjectId { get; private set; }
public PluginFellowshipCommandResult Recruit(uint targetObjectId)
{
RecruitedObjectId = targetObjectId;
return new(PluginFellowshipCommandStatus.Accepted);
}
public PluginFellowshipCommandResult Dismiss(uint targetObjectId)
{
DismissedObjectId = targetObjectId;
return new(PluginFellowshipCommandStatus.Accepted);
}
public PluginFellowshipCommandResult AssignLeader(uint targetObjectId)
{
AssignedLeaderObjectId = targetObjectId;
return new(PluginFellowshipCommandStatus.Accepted);
}
public PluginFellowshipCommandResult SetOpen(bool isOpen)
{
IsOpen = isOpen;
return new(PluginFellowshipCommandStatus.Accepted);
}
public PluginNavigationSnapshot Snapshot => new(
true,
false,
1u,
Position(0d),
false,
false);
public bool TryGetObject(uint objectId, out PluginNavigationObject value)
{
value = objectId == 2u
? new PluginNavigationObject(2u, "Alice", Position(0.02d))
: default;
return value.ObjectId != 0u;
}
public PluginNavigationCommandStatus SetMovementIntent(
in PluginMovementIntent intent) => PluginNavigationCommandStatus.Accepted;
public PluginNavigationCommandStatus ClearMovementIntent() =>
PluginNavigationCommandStatus.Accepted;
public List<string> Submitted { get; } = [];
public IReadOnlyList<PluginChatMessage> CaptureMessages(ulong afterSequence) =>
_messages.Where(value => value.Sequence > afterSequence).ToArray();
public void PostSystemMessage(string text)
{
}
public bool Submit(string text)
{
Submitted.Add(text);
return true;
}
public void AddTell(uint senderId, string sender, string text) =>
_messages.Add(new PluginChatMessage(
++_sequence, senderId, 3, sender, text, string.Empty));
private static PluginNavigationPosition Position(double eastWest) => new(
0x00010001u, eastWest, 0d, 0d, 0f, true);
}
}

View file

@ -0,0 +1,25 @@
using AcDream.Plugins.MossTank;
namespace AcDream.Plugins.MossTank.Tests;
public sealed class GrenadeCatalogTests
{
[Fact]
public void OfficialCatalog_containsExact72EntriesAndRepresentativeEdges()
{
Assert.Equal(72, GrenadeCatalog.All.Count);
Assert.True(GrenadeCatalog.TryGet(
"Iron Phial of Imperil",
out GrenadeDefinition iron));
Assert.Equal((1323u, 100, 75),
(iron.SpellId, iron.Spellcraft, iron.RequiredAlchemy));
Assert.True(GrenadeCatalog.TryGet(
"Mana Phial of Fester",
out GrenadeDefinition mana));
Assert.Equal((2178u, 520, 400),
(mana.SpellId, mana.Spellcraft, mana.RequiredAlchemy));
Assert.False(GrenadeCatalog.TryGet(
"mana phial of fester",
out _));
}
}

View file

@ -0,0 +1,669 @@
using AcDream.Plugin.Abstractions;
using AcDream.Plugins.MossTank.Expressions;
namespace AcDream.Plugins.MossTank.Tests;
public sealed class HostExpressionFunctionsTests
{
[Fact]
public void CharacterAndNavigationFunctionsReadCanonicalAutomationFacts()
{
var automation = CreateAutomation();
using var runtime = new MossTankExpressionRuntime(new Host(automation));
Assert.Equal("Coldeve", runtime.Evaluate("getworldname[]").AsString());
Assert.Equal(2d, runtime.Evaluate("getcharacterindex[]").AsNumber());
Assert.Equal(90d, runtime.Evaluate("getcharvital_current[1]").AsNumber());
Assert.Equal(100d, runtime.Evaluate("getcharvital_buffedmax[1]").AsNumber());
Assert.Equal(100d, runtime.Evaluate("getcharattribute_base[1]").AsNumber());
Assert.Equal(110d, runtime.Evaluate("getcharattribute_buffed[1]").AsNumber());
Assert.Equal(275d, runtime.Evaluate("getcharskill_base[34]").AsNumber());
Assert.Equal(300d, runtime.Evaluate("getcharskill_buffed[34]").AsNumber());
Assert.Equal(2d, runtime.Evaluate("getcharskill_traininglevel[34]").AsNumber());
Assert.Equal(0x7F7Fu, runtime.Evaluate("getplayerlandblock[]").AsNumber());
Assert.Equal(90d, runtime.Evaluate("getheading[wobjectgetplayer[]]").AsNumber());
}
[Fact]
public void ObjectDiscoveryCountsPropertiesAndNearestMatchUtilityBeltShape()
{
var automation = CreateAutomation();
using var runtime = new MossTankExpressionRuntime(new Host(automation));
Assert.Equal(2d, runtime.Evaluate(
"listcount[wobjectfindallinventory[]]").AsNumber());
Assert.Equal(10d, runtime.Evaluate(
"getitemcountininventorybyname['Health Elixir']").AsNumber());
Assert.Equal(9d, runtime.Evaluate("getfreeitemslots[]").AsNumber());
Assert.Equal(1d, runtime.Evaluate("getfreecontainerslots[]").AsNumber());
Assert.Equal("Drudge", runtime.Evaluate(
"wobjectgetname[wobjectfindnearestmonster[]]").AsString());
Assert.Equal(77d, runtime.Evaluate(
"wobjectgetintprop[wobjectfindbyid[20],25]").AsNumber());
Assert.Equal(2d, runtime.Evaluate(
"listcount[wobjectfindallbynamerx['(?i)elixir|drudge']]").AsNumber());
Assert.Equal((double)PluginObjectClass.Monster, runtime.Evaluate(
"wobjectgetobjectclass[wobjectfindbyid[20]]").AsNumber());
Assert.Equal(54321d, runtime.Evaluate(
"wobjectlastidtime[wobjectfindbyid[20]]").AsNumber());
}
[Fact]
public void ActionFunctionsUseSharedSelectionInventoryMagicAndMovementCommands()
{
var automation = CreateAutomation();
var host = new Host(automation);
using var runtime = new MossTankExpressionRuntime(host);
Assert.True(runtime.Evaluate("actiontryselect[20]").IsTruthy);
Assert.Equal(20u, host.Selection.SelectedObjectId);
Assert.True(runtime.Evaluate("actiontryuseitem[10]").IsTruthy);
Assert.Equal(10u, automation.UsedObject);
Assert.Equal(1d, runtime.Evaluate("actiontrycastbyidontarget[1001,20]").AsNumber());
Assert.Equal((1001u, 20u), automation.LastCast);
Assert.True(runtime.Evaluate("setmotion['Forward',1]").IsTruthy);
Assert.True(automation.LastIntent.Forward);
Assert.True(runtime.Evaluate("clearmotion[]").IsTruthy);
Assert.Equal(1, automation.ClearMovementCount);
}
[Fact]
public void LoginFunctionsUseTheAuthoritativeSortedRosterAndOneShotOwner()
{
var automation = CreateAutomation();
automation.LoginRoster =
[
new PluginLoginCharacter(10u, "Beta", 2, false),
new PluginLoginCharacter(1u, "Expression Tester", 0, false),
new PluginLoginCharacter(30u, "Mule", 1, false),
];
using var runtime = new MossTankExpressionRuntime(new Host(automation));
Assert.Equal(1d, runtime.Evaluate("getcharacterindex[]").AsNumber());
Assert.Equal(0d, runtime.Evaluate(
"getcharacterindex['bet']").AsNumber());
Assert.True(runtime.Evaluate("setnextlogin[1]").IsTruthy);
Assert.Equal(30u, automation.NextLoginObjectId);
Assert.True(runtime.Evaluate("setnextlogin['bet']").IsTruthy);
Assert.Equal(10u, automation.NextLoginObjectId);
Assert.True(runtime.Evaluate("clearnextlogin[]").IsTruthy);
Assert.Equal(0u, automation.NextLoginObjectId);
}
[Fact]
public void NetworkClientsReturnUtilityBeltDictionariesAndTagFiltering()
{
var automation = CreateAutomation();
automation.NetworkClients =
[
new PluginNetworkClient(
7u,
70u,
"Remote Mule",
"Coldeve",
automation.Position,
["mules", "trade"],
90u,
70u,
80u,
100u,
100u,
100u,
90f),
];
using var runtime = new MossTankExpressionRuntime(new Host(automation));
ExpressionList clients = runtime.Evaluate("netclients['mules']").AsList();
ExpressionDictionary client = Assert.Single(clients.Items).AsDictionary();
Assert.Equal("Remote Mule", client.Items["Name"].AsString());
Assert.Equal(70d, client.Items["PlayerId"].AsNumber());
Assert.Equal(2, client.Items["Tags"].AsList().Items.Count);
Assert.Empty(runtime.Evaluate("netclients['combat']").AsList().Items);
}
[Fact]
public void DelayedExecutionUsesTickAndCanBeCancelled()
{
using var runtime = new MossTankExpressionRuntime(
new Host(CreateAutomation()));
double id = runtime.Evaluate(
"delayexec[100,\"setvar['done',1]\"]").AsNumber();
runtime.OnTick(0.099);
Assert.Equal(0d, runtime.Evaluate("getvar['done']").AsNumber());
runtime.OnTick(0.001);
Assert.Equal(1d, runtime.Evaluate("getvar['done']").AsNumber());
double cancelled = runtime.Evaluate(
"delayexec[1,\"setvar['bad',1]\"]").AsNumber();
Assert.True(runtime.Evaluate($"clearexec[{cancelled}]").IsTruthy);
runtime.OnTick(1d);
Assert.Equal(0d, runtime.Evaluate("getvar['bad']").AsNumber());
Assert.NotEqual(id, cancelled);
}
[Fact]
public void PersistentAndGlobalVariablesRoundTripThroughPluginStorage()
{
var storage = new MemoryStorage();
var automation = CreateAutomation();
using (var first = new MossTankExpressionRuntime(new Host(automation, storage)))
{
first.Evaluate("setpvar['count',42];setgvar['names',listcreate['a','b']]");
}
using var second = new MossTankExpressionRuntime(
new Host(CreateAutomation(), storage));
Assert.Equal(42d, second.Evaluate("getpvar['count']").AsNumber());
Assert.Equal(2d, second.Evaluate("listcount[getgvar['names']]").AsNumber());
Assert.Contains(
storage.Text.Keys,
static key => key.StartsWith("expressions/persistent/", StringComparison.Ordinal));
Assert.Contains(
storage.Text.Keys,
static key => key.StartsWith("expressions/global/", StringComparison.Ordinal));
}
[Fact]
public void FellowshipFunctionsExposeTheAuthoritativeCompleteRoster()
{
var automation = CreateAutomation();
automation.FellowRoster =
[
new PluginFellowMember(1, "Expression Tester", 90, 100, 80, 100, 70, 100, 0),
new PluginFellowMember(22, "Fellow Two", 50, 60, 40, 60, 30, 60, 1),
];
using var runtime = new MossTankExpressionRuntime(new Host(automation));
Assert.True(runtime.Evaluate("getfellowshipstatus[]").IsTruthy);
Assert.Equal("Test Fellowship", runtime.Evaluate("getfellowshipname[]").AsString());
Assert.Equal(2d, runtime.Evaluate("getfellowshipcount[]").AsNumber());
Assert.True(runtime.Evaluate("getfellowshipisleader[]").IsTruthy);
Assert.True(runtime.Evaluate("getfellowshipcanrecruit[]").IsTruthy);
Assert.Equal("Fellow Two", runtime.Evaluate("getfellowname[1]").AsString());
Assert.Equal(22d, runtime.Evaluate("getfellowid[1]").AsNumber());
Assert.Equal(2d, runtime.Evaluate("listcount[getfellowids[]]").AsNumber());
}
[Fact]
public void DerethTimeFunctionsUseTheRuntimeWorldClockProjection()
{
var automation = CreateAutomation();
automation.Time = new PluginWorldTimeSnapshot(
true,
123456d,
142,
6,
17,
10,
"HarvestGain",
"Warmtide",
true,
0d,
12.5d);
using var runtime = new MossTankExpressionRuntime(new Host(automation));
Assert.Equal(142d, runtime.Evaluate("getgameyear[]").AsNumber());
Assert.Equal(6d, runtime.Evaluate("getgamemonth[]").AsNumber());
Assert.Equal("HarvestGain", runtime.Evaluate("getgamemonthname[6]").AsString());
Assert.Equal(17d, runtime.Evaluate("getgameday[]").AsNumber());
Assert.Equal("Warmtide", runtime.Evaluate("getgamehourname[10]").AsString());
Assert.Equal(123456d, runtime.Evaluate("getgameticks[]").AsNumber());
Assert.True(runtime.Evaluate("getisday[]").IsTruthy);
Assert.Equal(12.5d, runtime.Evaluate("getminutesuntilnight[]").AsNumber());
}
[Fact]
public void ExperienceMeterAccumulatesCanonicalXpAndLuminanceDeltas()
{
var automation = CreateAutomation();
automation.Properties[1] = Properties(
ints: new Dictionary<uint, int> { [5] = 50, [96] = 100 },
int64s: new Dictionary<uint, long> { [1] = 1000, [6] = 10 });
using var runtime = new MossTankExpressionRuntime(new Host(automation));
runtime.OnTick(1d);
automation.Properties[1] = Properties(
ints: new Dictionary<uint, int> { [5] = 50, [96] = 100 },
int64s: new Dictionary<uint, long> { [1] = 1100, [6] = 15 });
runtime.OnTick(1d);
Assert.Equal(100d, runtime.Evaluate("xptotal[]").AsNumber());
Assert.Equal(5d, runtime.Evaluate("lumtotal[]").AsNumber());
Assert.Equal(2d, runtime.Evaluate("xpduration[]").AsNumber());
Assert.Equal(180000d, runtime.Evaluate("xpavg[]").AsNumber());
Assert.Contains("100 XP", runtime.Evaluate("xpmeter[]").AsString());
Assert.True(runtime.Evaluate("xpreset[]").IsTruthy);
Assert.Equal(0d, runtime.Evaluate("xptotal[]").AsNumber());
}
[Fact]
public void QuestFunctionsParseTheAuthoritativeMyquestsTranscript()
{
var automation = CreateAutomation();
using var runtime = new MossTankExpressionRuntime(new Host(automation));
Assert.Equal(["/myquests"], automation.SubmittedChat);
Assert.True(runtime.Evaluate("isrefreshingquests[]").IsTruthy);
automation.ChatMessages.Add(new PluginChatMessage(
1, 0, 0, string.Empty,
"killtaskdrudges - 3 solves (0)\"Drudges killed\" 10 0",
string.Empty));
automation.ChatMessages.Add(new PluginChatMessage(
2, 0, 0, string.Empty,
"onevisit - 1 solves (1700000000)\"Visited once\" 1 0",
string.Empty));
runtime.OnTick(0d);
Assert.True(runtime.Evaluate("testquestflag['killtaskdrudges']").IsTruthy);
Assert.Equal(3d, runtime.Evaluate(
"getquestktprogress['killtaskdrudges']").AsNumber());
Assert.Equal(10d, runtime.Evaluate(
"getquestktrequired['killtaskdrudges']").AsNumber());
Assert.False(runtime.Evaluate("getqueststatus['onevisit']").IsTruthy);
Assert.True(runtime.Evaluate("getqueststatus['unknown']").IsTruthy);
runtime.OnTick(1.001d);
Assert.False(runtime.Evaluate("isrefreshingquests[]").IsTruthy);
}
[Fact]
public void CorpseFunctionsUseTheCanonicalExternalContainerHistory()
{
var automation = CreateAutomation();
automation.Corpses =
[
new PluginLootContainer(30, 300, "Corpse", 2f, false, false, false),
new PluginLootContainer(31, 301, "Corpse", 3f, true, false, false),
];
using var runtime = new MossTankExpressionRuntime(new Host(automation));
Assert.False(runtime.Evaluate("hascorpsebeenopenedbyme[30]").IsTruthy);
Assert.True(runtime.Evaluate("hascorpsebeenopenedbyme[31]").IsTruthy);
Assert.Equal(30d, runtime.Evaluate(
"listgetitem[getcorpsesunopenedbyme[],0]").AsNumber());
}
[Fact]
public void ComponentFunctionsUseTheRetailDatCatalogProjection()
{
var automation = CreateAutomation();
automation.Components[7] = new PluginSpellComponentInfo(
7, 101, "Malar Herb", 0.25, 0x13000001, 0.75,
0x06000010, 4, "Herb", "Malar");
using var runtime = new MossTankExpressionRuntime(new Host(automation));
Assert.Equal("Malar Herb", runtime.Evaluate("componentname[7]").AsString());
Assert.Equal(0.25d, runtime.Evaluate(
"dictgetitem[componentdata[7],'BurnRate']").AsNumber());
Assert.Equal("Malar", runtime.Evaluate(
"dictgetitem[componentdata[7],'Word']").AsString());
}
[Fact]
public void UstFunctionsStageAndSubmitThroughTheCanonicalSalvageCommand()
{
var automation = CreateAutomation();
automation.InventoryItems =
[
InventoryItem(40, "Ust"),
InventoryItem(41, "Salvage One"),
InventoryItem(42, "Salvage Two"),
];
using var runtime = new MossTankExpressionRuntime(new Host(automation));
Assert.True(runtime.Evaluate("ustopen[]").IsTruthy);
Assert.Equal(40u, automation.UsedObject);
Assert.True(runtime.Evaluate("ustadd[41]").IsTruthy);
Assert.True(runtime.Evaluate("ustadd[42]").IsTruthy);
Assert.True(runtime.Evaluate("ustsalvage[]").IsTruthy);
Assert.Equal(40u, automation.LastSalvage.Tool);
Assert.Equal([41u, 42u], automation.LastSalvage.Items);
Assert.False(runtime.Evaluate("ustsalvage[]").IsTruthy);
}
private static Automation CreateAutomation()
{
var automation = new Automation
{
Position = new PluginNavigationPosition(
0x7F7F0001u, 10d, 20d, 3d, 90f, true),
Attributes =
[
new PluginAttributeInfo(0, "Strength", 110) { Base = 100 },
],
Skills =
[
new PluginSkillInfo(34, "War Magic", PluginSkillTraining.Trained, 300)
{
Base = 275,
},
],
};
automation.WorldObjects.Add(new PluginWorldObject(
1, 1, "Expression Tester", PluginObjectClass.Player, 0x10, 0, 0)
{
HasPosition = true,
Position = automation.Position,
ItemsCapacity = 10,
ContainersCapacity = 2,
});
automation.WorldObjects.Add(new PluginWorldObject(
10, 100, "Health Elixir", PluginObjectClass.Food, 0x20, 1, 0)
{
IsOwned = true,
StackSize = 10,
});
automation.WorldObjects.Add(new PluginWorldObject(
11, 101, "Small Pack", PluginObjectClass.Container, 0x200, 1, 0)
{
IsOwned = true,
ItemsCapacity = 8,
});
automation.WorldObjects.Add(new PluginWorldObject(
20, 200, "Drudge", PluginObjectClass.Monster, 0x10, 0, 0)
{
IsLandscape = true,
HasPosition = true,
Position = automation.Position with { EastWest = 10.1d },
HasAppraisalData = true,
LastIdTime = 54321,
});
automation.Properties[1] = Properties(
ints: new Dictionary<uint, int> { [5] = 50, [96] = 100 },
strings: new Dictionary<uint, string> { [1] = "Expression Tester" });
automation.Properties[20] = Properties(
ints: new Dictionary<uint, int> { [25] = 77 });
return automation;
}
private static PluginItemProperties Properties(
IReadOnlyDictionary<uint, int>? ints = null,
IReadOnlyDictionary<uint, long>? int64s = null,
IReadOnlyDictionary<uint, string>? strings = null) => new(
ints ?? new Dictionary<uint, int>(),
int64s ?? new Dictionary<uint, long>(),
new Dictionary<uint, bool>(),
new Dictionary<uint, double>(),
strings ?? new Dictionary<uint, string>(),
new Dictionary<uint, uint>(),
new Dictionary<uint, uint>());
private static PluginInventoryItem InventoryItem(uint id, string name) => new(
id, 0u, name, 0u, 1u, 0u, 0u, 0u, 0u, 0u, 0u,
1, 0, 0, 0u, 0, 0, 0u, false, 0d, 0, 0, 0, 0d, 0, 0, 0);
private sealed class Host(
Automation automation,
IPluginStorage? storage = null) : IPluginHost
{
public bool HasUi => false;
public IPluginLogger Log { get; } = new Logger();
public IGameState State { get; } = new State();
public IEvents Events { get; } = new Events();
public Selection Selection { get; } = new();
ISelectionService IPluginHost.Selection => Selection;
public IUiRegistry Ui => NoOpUiRegistry.Instance;
public IPluginStorage Storage { get; } = storage ?? NoOpPluginStorage.Instance;
public IAutomationSurface Automation { get; } = automation;
}
private sealed class Automation :
IAutomationSurface,
ICharacterInfo,
ISpellCatalog,
IMagicCommands,
IPluginChat,
IWorldObjectAutomation,
IItemAutomation,
INavigationAutomation,
IFellowshipAutomation,
IWorldTimeAutomation,
ILootAutomation,
ILoginAutomation,
INetworkAutomation
{
public bool IsAvailable => true;
public ICharacterInfo Character => this;
public ISpellCatalog Spells => this;
public IMagicCommands Magic => this;
public IPluginChat Chat => this;
public IWorldObjectAutomation Objects => this;
public IItemAutomation Items => this;
public INavigationAutomation Navigation => this;
public IFellowshipAutomation Fellowship => this;
public ILootAutomation Loot => this;
public IWorldTimeAutomation WorldTime => this;
public ILoginAutomation Login => this;
public INetworkAutomation Network => this;
public bool IsInWorld => true;
public string Name => "Expression Tester";
public string WorldName => "Coldeve";
public string AccountName => "testaccount";
public int CharacterIndex => 2;
public uint ObjectId => 1;
public uint CurrentHealth => 90;
public uint MaxHealth => 100;
public uint CurrentStamina => 80;
public uint MaxStamina => 100;
public uint CurrentMana => 70;
public uint MaxMana => 100;
public IReadOnlyList<PluginSkillInfo> Skills { get; set; } = [];
public IReadOnlyList<PluginAttributeInfo> Attributes { get; set; } = [];
public IReadOnlyList<PluginActiveEnchantment> ActiveEnchantments => [];
public IReadOnlyList<PluginSpellInfo> KnownSelfBuffs => [];
public IReadOnlyList<PluginSpellInfo> KnownAttackSpells => [];
public IReadOnlyList<PluginSpellInfo> KnownCombatSpells => [];
public List<PluginWorldObject> WorldObjects { get; } = [];
public Dictionary<uint, PluginItemProperties> Properties { get; } = [];
public Dictionary<uint, PluginSpellComponentInfo> Components { get; } = [];
public PluginNavigationPosition Position { get; set; }
public uint UsedObject { get; private set; }
public (uint SpellId, uint TargetId) LastCast { get; private set; }
public PluginMovementIntent LastIntent { get; private set; }
public int ClearMovementCount { get; private set; }
public List<PluginChatMessage> ChatMessages { get; } = [];
public List<string> SubmittedChat { get; } = [];
public IReadOnlyList<PluginInventoryItem> InventoryItems { get; set; } = [];
public (uint Tool, IReadOnlyList<uint> Items) LastSalvage { get; private set; }
public IReadOnlyList<PluginFellowMember> FellowRoster { get; set; } = [];
public IReadOnlyList<PluginLoginCharacter> LoginRoster { get; set; } = [];
public uint NextLoginObjectId { get; private set; }
public IReadOnlyList<PluginNetworkClient> NetworkClients { get; set; } = [];
public IReadOnlyList<PluginLootContainer> Corpses { get; set; } = [];
public IReadOnlyList<PluginLootContainer> CaptureCorpses(float maximumDistance) =>
Corpses.Where(corpse => corpse.Distance <= maximumDistance).ToArray();
public bool IsInFellowship => FellowRoster.Count != 0;
string IFellowshipAutomation.Name => "Test Fellowship";
public uint LeaderObjectId => 1;
public bool IsOpen => true;
public bool IsLocked => false;
public int MemberCount => FellowRoster.Count;
IReadOnlyList<PluginFellowMember> IFellowshipAutomation.CaptureRoster() =>
FellowRoster;
bool ILoginAutomation.IsAvailable => true;
IReadOnlyList<PluginLoginCharacter> ILoginAutomation.CaptureRoster() =>
LoginRoster;
public bool SetNextLogin(uint characterObjectId)
{
if (!LoginRoster.Any(character =>
character.ObjectId == characterObjectId
&& !character.IsPendingDelete))
{
return false;
}
NextLoginObjectId = characterObjectId;
return true;
}
public bool ClearNextLogin()
{
NextLoginObjectId = 0u;
return true;
}
bool INetworkAutomation.IsAvailable => true;
IReadOnlyList<PluginNetworkClient> INetworkAutomation.CaptureClients() =>
NetworkClients;
public PluginWorldTimeSnapshot Time { get; set; }
PluginWorldTimeSnapshot IWorldTimeAutomation.Snapshot => Time;
public bool TryGetSkill(uint skillId, out PluginSkillInfo skill)
{
foreach (PluginSkillInfo candidate in Skills)
{
if (candidate.SkillId == skillId)
{
skill = candidate;
return true;
}
}
skill = default;
return false;
}
public bool IsKnown(uint spellId) => spellId == 1001;
public bool TryGet(uint spellId, out PluginSpellInfo info)
{
info = default;
return false;
}
public bool TryGetComponent(uint componentId, out PluginSpellComponentInfo info) =>
Components.TryGetValue(componentId, out info);
public bool IsCasting => false;
public PluginCastGate EvaluateGate(uint spellId) =>
spellId == 1001 ? PluginCastGate.Ready : PluginCastGate.NotKnown;
public PluginCastGate EvaluateGate(uint spellId, uint targetObjectId) =>
EvaluateGate(spellId);
public bool Cast(uint spellId) => Cast(spellId, 0);
public bool Cast(uint spellId, uint targetObjectId)
{
LastCast = (spellId, targetObjectId);
return true;
}
public IReadOnlyList<PluginWorldObject> CaptureObjects() => WorldObjects;
public bool TryGet(uint objectId, out PluginWorldObject value)
{
foreach (PluginWorldObject candidate in WorldObjects)
{
if (candidate.ObjectId == objectId)
{
value = candidate;
return true;
}
}
value = default;
return false;
}
public bool TryCaptureProperties(uint objectId, out PluginItemProperties value) =>
Properties.TryGetValue(objectId, out value);
public PluginItemCommandResult Use(uint objectId)
{
UsedObject = objectId;
return new PluginItemCommandResult(PluginItemCommandStatus.Started);
}
public IReadOnlyList<PluginInventoryItem> CaptureOwnedItems() => InventoryItems;
public PluginItemCommandResult Salvage(
uint toolObjectId,
IReadOnlyList<uint> itemObjectIds)
{
LastSalvage = (toolObjectId, itemObjectIds.ToArray());
return new PluginItemCommandResult(PluginItemCommandStatus.Started);
}
public PluginNavigationSnapshot Snapshot => new(
true,
false,
ObjectId,
Position,
false,
false);
public bool TryGetObject(uint objectId, out PluginNavigationObject value)
{
if (TryGet(objectId, out PluginWorldObject obj) && obj.HasPosition)
{
value = new PluginNavigationObject(obj.ObjectId, obj.Name, obj.Position);
return true;
}
value = default;
return false;
}
public PluginNavigationCommandStatus SetMovementIntent(
in PluginMovementIntent intent)
{
LastIntent = intent;
return PluginNavigationCommandStatus.Accepted;
}
public PluginNavigationCommandStatus ClearMovementIntent()
{
ClearMovementCount++;
return PluginNavigationCommandStatus.Accepted;
}
public void PostSystemMessage(string text) { }
public IReadOnlyList<PluginChatMessage> CaptureMessages(ulong afterSequence) =>
ChatMessages.Where(message => message.Sequence > afterSequence).ToArray();
public bool Submit(string text)
{
SubmittedChat.Add(text);
return true;
}
}
private sealed class Selection : ISelectionService
{
public uint? SelectedObjectId { get; private set; }
public uint? PreviousObjectId { get; private set; }
public event Action<SelectionChangedEvent> Changed
{
add { }
remove { }
}
public bool Select(uint objectId)
{
PreviousObjectId = SelectedObjectId;
SelectedObjectId = objectId;
return true;
}
public bool Clear()
{
PreviousObjectId = SelectedObjectId;
SelectedObjectId = null;
return true;
}
}
private sealed class MemoryStorage : IPluginStorage
{
public Dictionary<string, string> Text { get; } = [];
public bool IsAvailable => true;
public string? ReadText(string key) =>
Text.TryGetValue(key, out string? value) ? value : null;
public void WriteText(string key, string content) => Text[key] = content;
public bool Delete(string key) => Text.Remove(key);
}
private sealed class Logger : IPluginLogger
{
public void Info(string message) { }
public void Warn(string message) { }
public void Error(string message, Exception? exception = null) { }
}
private sealed class State : IGameState
{
public IReadOnlyList<WorldEntitySnapshot> Entities => [];
}
private sealed class Events : IEvents
{
public event Action<WorldEntitySnapshot> EntitySpawned
{
add { }
remove { }
}
public event Action<double> Tick
{
add { }
remove { }
}
}
}

View file

@ -0,0 +1,225 @@
using AcDream.Plugin.Abstractions;
namespace AcDream.Plugins.MossTank.Tests;
public sealed class InventoryMaintenanceTests
{
private const uint Player = 1u;
[Fact]
public void PlannerAlwaysStacksBeforeCramming()
{
var settings = new InventorySettings { AutoStack = true, AutoCram = true };
PluginInventoryItem pack = Item(10, "Pack", container: Player) with
{
ItemsCapacity = 24,
ContainerSlot = 1,
Burden = 8,
};
PluginInventoryItem source = Item(
20, "Arrow", wcid: 77, container: Player, stack: 10, maximum: 10) with
{
ContainerSlot = 2,
Burden = 1,
};
PluginInventoryItem target = Item(
21, "Arrow", wcid: 77, container: 10, stack: 6, maximum: 10) with
{
ContainerSlot = 0,
Burden = 1,
};
InventoryMaintenancePlan plan = Assert.IsType<InventoryMaintenancePlan>(
InventoryMaintenancePlanner.Plan(
[pack, source, target], Player, settings));
Assert.Equal(InventoryMaintenanceKind.Merge, plan.Kind);
Assert.Equal(source.ObjectId, plan.SourceObjectId);
Assert.Equal(target.ObjectId, plan.TargetObjectId);
Assert.Equal(4u, plan.Amount);
}
[Fact]
public void CramMovesOneMainPackItemIntoFirstSidePackWithRoom()
{
var settings = new InventorySettings { AutoStack = false, AutoCram = true };
PluginInventoryItem full = Item(10, "Full pack", container: Player) with
{
ItemsCapacity = 1,
ContainerSlot = 0,
};
PluginInventoryItem destination = Item(11, "Open pack", container: Player) with
{
ItemsCapacity = 2,
ContainerSlot = 1,
};
PluginInventoryItem occupant = Item(12, "Occupant", container: 10);
PluginInventoryItem source = Item(20, "Loose item", container: Player) with
{
ContainerSlot = 4,
};
PluginInventoryItem foci = Item(21, "Focus", container: Player) with
{
ContainerSlot = 2,
PublicFlags = 0x00800000u,
};
InventoryMaintenancePlan plan = Assert.IsType<InventoryMaintenancePlan>(
InventoryMaintenancePlanner.Plan(
[full, destination, occupant, foci, source],
Player,
settings));
Assert.Equal(InventoryMaintenanceKind.Cram, plan.Kind);
Assert.Equal(source.ObjectId, plan.SourceObjectId);
Assert.Equal(destination.ObjectId, plan.TargetObjectId);
}
[Fact]
public void ControllerWaitsForAuthoritativeReceiptBeforeReplanning()
{
var automation = new Automation
{
Inventory =
[
Item(20, "Arrow", 77, Player, 3, 10),
Item(21, "Arrow", 77, Player, 8, 10),
],
};
var controller = new InventoryMaintenanceController(
new Host(automation),
new InventorySettings { AutoStack = true });
Assert.True(controller.Tick(1d, canAct: true));
Assert.Equal(new[] { (20u, 21u, 2u) }, automation.Merges);
Assert.True(controller.Tick(1d, canAct: true));
Assert.Single(automation.Merges);
automation.Busy = false;
automation.Completion = new PluginInventoryCompletion(
1,
PluginInventoryCommandKind.Merge,
20u,
0u);
automation.Inventory =
[
Item(21, "Arrow", 77, Player, 10, 10),
];
Assert.False(controller.Tick(1d, canAct: true));
Assert.Single(automation.Merges);
Assert.Equal("Stack/Cram idle", controller.Status);
}
private static PluginInventoryItem Item(
uint id,
string name,
uint wcid = 0u,
uint container = Player,
int stack = 1,
int maximum = 1) => new(
id, wcid, name, 0x80u, container, 0u, 0u, 0u, 0u, 0u, 0u,
stack, 0, 0, 0u, 0, 0, 0u, false, 0d, 0, 0, 0, 0d, 0, 0, 0)
{
MaximumStackSize = maximum,
};
private sealed class Automation
: IAutomationSurface, ICharacterInfo, IItemAutomation, IPluginChat
{
public bool IsAvailable => true;
public ICharacterInfo Character => this;
public ISpellCatalog Spells => NoOpAutomationSurface.Instance;
public IMagicCommands Magic => NoOpAutomationSurface.Instance;
public IPluginChat Chat => this;
public IItemAutomation Items => this;
public bool IsInWorld => true;
public uint ObjectId => Player;
public uint CurrentHealth => 0;
public uint MaxHealth => 0;
public uint CurrentStamina => 0;
public uint MaxStamina => 0;
public uint CurrentMana => 0;
public uint MaxMana => 0;
public IReadOnlyList<PluginSkillInfo> Skills => [];
public IReadOnlyList<PluginAttributeInfo> Attributes => [];
public IReadOnlyList<PluginActiveEnchantment> ActiveEnchantments => [];
public bool IsBusy => Busy;
public bool Busy { get; set; }
public PluginInventoryCompletion Completion { get; set; }
public PluginInventoryCompletion LastInventoryCompletion => Completion;
public IReadOnlyList<PluginInventoryItem> Inventory { get; set; } = [];
public List<(uint Source, uint Target, uint Amount)> Merges { get; } = [];
public IReadOnlyList<PluginInventoryItem> CaptureOwnedItems() => Inventory;
public PluginItemCommandResult Merge(
uint sourceObjectId,
uint targetObjectId,
uint amount = 0u)
{
Merges.Add((sourceObjectId, targetObjectId, amount));
Busy = true;
return new(PluginItemCommandStatus.Started);
}
public bool TryGetSkill(uint skillId, out PluginSkillInfo skill)
{
skill = default;
return false;
}
public void PostSystemMessage(string text) { }
}
private sealed class Host(IAutomationSurface automation) : IPluginHost
{
public bool HasUi => false;
public IPluginLogger Log => Logger.Instance;
public IGameState State => EmptyState.Instance;
public IEvents Events => EmptyEvents.Instance;
public ISelectionService Selection => EmptySelection.Instance;
public IUiRegistry Ui => NoOpUiRegistry.Instance;
public IAutomationSurface Automation { get; } = automation;
}
private sealed class Logger : IPluginLogger
{
public static Logger Instance { get; } = new();
public void Info(string message) { }
public void Warn(string message) { }
public void Error(string message, Exception? exception = null) { }
}
private sealed class EmptyState : IGameState
{
public static EmptyState Instance { get; } = new();
public IReadOnlyList<WorldEntitySnapshot> Entities => [];
}
private sealed class EmptyEvents : IEvents
{
public static EmptyEvents Instance { get; } = new();
public event Action<WorldEntitySnapshot> EntitySpawned
{
add { }
remove { }
}
public event Action<double> Tick
{
add { }
remove { }
}
}
private sealed class EmptySelection : ISelectionService
{
public static EmptySelection Instance { get; } = new();
public uint? SelectedObjectId => null;
public uint? PreviousObjectId => null;
public event Action<SelectionChangedEvent> Changed
{
add { }
remove { }
}
public bool Select(uint objectId) => false;
public bool Clear() => false;
}
}

View file

@ -0,0 +1,73 @@
using AcDream.Plugin.Abstractions;
namespace AcDream.Plugins.MossTank.Tests;
public sealed class ItemManaRechargeTests
{
[Fact]
public void PlannerUsesProfiledManaChargeOnMostDepletedWornItem()
{
var names = new HashSet<string>(StringComparer.Ordinal)
{
"Mana Charge",
};
PluginInventoryItem planTarget = Item(20, "Low Wand") with
{
EquippedLocation = 0x01000000u,
ItemCurrentMana = 10,
ItemMaximumMana = 100,
};
ItemManaRechargePlan plan = Assert.IsType<ItemManaRechargePlan>(
ItemManaRechargePlanner.Plan(
[
Item(10, "Mana Charge", 0x00080000u) with
{
ItemCurrentMana = 100,
},
planTarget,
Item(21, "Other Wand") with
{
EquippedLocation = 0x01000000u,
ItemCurrentMana = 20,
ItemMaximumMana = 100,
},
],
names,
thresholdPercent: 33));
Assert.Equal(10u, plan.ChargeObjectId);
Assert.Equal(20u, plan.TargetObjectId);
Assert.Equal(10, plan.CurrentMana);
}
[Fact]
public void PlannerRequiresProfileMembershipAndBelowThreshold()
{
PluginInventoryItem charge = Item(10, "Mana Charge", 0x00080000u) with
{
ItemCurrentMana = 100,
};
PluginInventoryItem wand = Item(20, "Wand") with
{
EquippedLocation = 0x01000000u,
ItemCurrentMana = 34,
ItemMaximumMana = 100,
};
Assert.Null(ItemManaRechargePlanner.Plan(
[charge, wand],
new HashSet<string>(StringComparer.Ordinal),
33));
Assert.Null(ItemManaRechargePlanner.Plan(
[charge, wand],
new HashSet<string>(StringComparer.Ordinal) { "Mana Charge" },
33));
}
private static PluginInventoryItem Item(
uint id,
string name,
uint itemType = 0x80u) => new(
id, 0u, name, itemType, 1u, 0u, 0u, 0u, 0u, 0u, 0u,
1, 0, 0, 0u, 0, 0, 0u, false, 0d, 0, 0, 0, 0d, 0, 0, 0);
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,286 @@
using AcDream.Plugin.Abstractions;
using AcDream.Plugins.MossTank.Expressions;
namespace AcDream.Plugins.MossTank.Tests;
public sealed class MetaEngineTests
{
[Fact]
public void RuleFiresOncePerStateEntryAndCanFireAfterReentry()
{
var host = new Host();
using var expressions = new MossTankExpressionRuntime(host);
var profile = new MetaProfile
{
Rules =
[
Rule(MetaConditionKind.Always, MetaActionKind.ExpressionAction,
"setvar['count',getvar['count']+1]"),
],
};
var engine = new MetaEngine(host, expressions, profile);
engine.SetEnabled(true);
engine.EvaluatePass();
engine.EvaluatePass();
Assert.Equal(1d, expressions.Evaluate("getvar['count']").AsNumber());
Assert.Equal(1, engine.FiredRuleCount);
engine.Transition(MetaEngine.DefaultState);
engine.EvaluatePass();
Assert.Equal(2d, expressions.Evaluate("getvar['count']").AsNumber());
}
[Fact]
public void StateTransitionStopsTheOldStatesOrderedPass()
{
var host = new Host();
using var expressions = new MossTankExpressionRuntime(host);
var profile = new MetaProfile
{
Rules =
[
Rule(MetaConditionKind.Always, MetaActionKind.SetMetaState, "Next"),
Rule(MetaConditionKind.Always, MetaActionKind.ExpressionAction,
"setvar['wrong',1]"),
Rule(MetaConditionKind.Always, MetaActionKind.ExpressionAction,
"setvar['right',1]", "Next"),
],
};
var engine = new MetaEngine(host, expressions, profile);
engine.SetEnabled(true);
engine.EvaluatePass();
Assert.Equal("Next", engine.CurrentState);
Assert.Equal(0d, expressions.Evaluate("getvar['wrong']").AsNumber());
engine.EvaluatePass();
Assert.Equal(1d, expressions.Evaluate("getvar['right']").AsNumber());
}
[Fact]
public void CallAndReturnUseTheVtankReturnStateStack()
{
var host = new Host();
using var expressions = new MossTankExpressionRuntime(host);
var call = Rule(MetaConditionKind.Always, MetaActionKind.CallMetaState, "Worker");
call.Action.SecondaryText = "ReturnHere";
var profile = new MetaProfile
{
Rules =
[
call,
Rule(MetaConditionKind.Always, MetaActionKind.ReturnFromCall, state: "Worker"),
Rule(MetaConditionKind.Always, MetaActionKind.ExpressionAction,
"setvar['returned',1]", "ReturnHere"),
],
};
var engine = new MetaEngine(host, expressions, profile);
engine.SetEnabled(true);
engine.EvaluatePass();
Assert.Equal("Worker", engine.CurrentState);
Assert.Equal(1, engine.CallDepth);
engine.EvaluatePass();
Assert.Equal("ReturnHere", engine.CurrentState);
Assert.Equal(0, engine.CallDepth);
engine.EvaluatePass();
Assert.Equal(1d, expressions.Evaluate("getvar['returned']").AsNumber());
}
[Fact]
public void ChatCapturePublishesGroupsAndColorToExpressionVariables()
{
var host = new Host();
using var expressions = new MossTankExpressionRuntime(host);
var condition = new MetaCondition
{
Kind = MetaConditionKind.ChatMessageCapture,
Text = "^(?<name>.+) tells you, \\\"(?<words>.+)\\\"$",
SecondaryText = "3;4",
};
var profile = new MetaProfile
{
Rules =
[
new MetaRule
{
Condition = condition,
Action = new MetaAction
{
Kind = MetaActionKind.ExpressionAction,
Text = "setvar['matched',1]",
},
},
],
};
var engine = new MetaEngine(host, expressions, profile);
engine.SetEnabled(true);
host.Automation.Messages.Add(new PluginChatMessage(
1, 20, 3, "Horan", "Horan tells you, \"ready\"", "Tells"));
engine.OnTick(MetaEngine.DecisionIntervalSeconds);
Assert.Equal("Horan", expressions.Evaluate(
"getvar['capturegroup_name']").AsString());
Assert.Equal("ready", expressions.Evaluate(
"getvar['capturegroup_words']").AsString());
Assert.Equal(3d, expressions.Evaluate("getvar['capturecolor']").AsNumber());
Assert.Equal(1d, expressions.Evaluate("getvar['matched']").AsNumber());
}
[Fact]
public void WatchdogCallsRecoveryStateOnlyWhenMovementStalls()
{
var host = new Host();
using var expressions = new MossTankExpressionRuntime(host);
var setWatchdog = Rule(MetaConditionKind.Always, MetaActionKind.SetWatchdog);
setWatchdog.Action.Text = "Recover";
setWatchdog.Action.Number = 5d;
setWatchdog.Action.SecondaryNumber = 1d;
var profile = new MetaProfile { Rules = [setWatchdog] };
var engine = new MetaEngine(host, expressions, profile);
engine.SetEnabled(true);
engine.EvaluatePass();
for (int index = 0; index < 13; index++)
engine.OnTick(0.1d);
Assert.Equal("Recover", engine.CurrentState);
Assert.Equal(1, engine.CallDepth);
}
[Fact]
public void RecursiveCallOverflowDisablesMetaLikeVtank()
{
var host = new Host();
using var expressions = new MossTankExpressionRuntime(host);
var call = Rule(MetaConditionKind.Always, MetaActionKind.CallMetaState, "Loop");
call.Action.SecondaryText = "Loop";
call.State = "Loop";
var engine = new MetaEngine(
host,
expressions,
new MetaProfile { Rules = [call] });
engine.Transition("Loop");
engine.SetEnabled(true);
for (int index = 0; index <= MetaEngine.MaximumCallDepth; index++)
engine.EvaluatePass();
Assert.False(engine.Enabled);
Assert.Contains("overflow", engine.Status, StringComparison.OrdinalIgnoreCase);
}
private static MetaRule Rule(
MetaConditionKind condition,
MetaActionKind action,
string text = "",
string state = MetaEngine.DefaultState) => new()
{
State = state,
Condition = new MetaCondition { Kind = condition },
Action = new MetaAction { Kind = action, Text = text },
};
private sealed class Host : IPluginHost
{
public Host() => Automation = new Automation();
public bool HasUi => false;
public IPluginLogger Log { get; } = new Logger();
public IGameState State { get; } = new State();
public IEvents Events { get; } = new Events();
public ISelectionService Selection { get; } = new Selection();
public IUiRegistry Ui => NoOpUiRegistry.Instance;
public IPluginStorage Storage => NoOpPluginStorage.Instance;
public Automation Automation { get; }
IAutomationSurface IPluginHost.Automation => Automation;
}
private sealed class Automation :
IAutomationSurface,
ICharacterInfo,
IPluginChat,
INavigationAutomation
{
public bool IsAvailable => true;
public ICharacterInfo Character => this;
public ISpellCatalog Spells => NoOpAutomationSurface.Instance;
public IMagicCommands Magic => NoOpAutomationSurface.Instance;
public IPluginChat Chat => this;
public INavigationAutomation Navigation => this;
public bool IsInWorld => true;
public string Name => "Meta Tester";
public string WorldName => "Coldeve";
public string AccountName => "testaccount";
public uint ObjectId => 1;
public uint CurrentHealth { get; set; } = 100;
public uint MaxHealth => 100;
public uint CurrentStamina => 100;
public uint MaxStamina => 100;
public uint CurrentMana => 100;
public uint MaxMana => 100;
public IReadOnlyList<PluginSkillInfo> Skills => [];
public IReadOnlyList<PluginAttributeInfo> Attributes => [];
public IReadOnlyList<PluginActiveEnchantment> ActiveEnchantments => [];
public List<PluginChatMessage> Messages { get; } = [];
public bool IsPortal { get; set; }
public PluginNavigationPosition Position { get; set; } = new(
0x7F7F0001u, 10d, 20d, 0d, 0f, true);
public bool TryGetSkill(uint skillId, out PluginSkillInfo skill)
{
skill = default;
return false;
}
public IReadOnlyList<PluginChatMessage> CaptureMessages(ulong afterSequence) =>
Messages.Where(message => message.Sequence > afterSequence).ToArray();
public void PostSystemMessage(string text) { }
public PluginNavigationSnapshot Snapshot => new(
true, IsPortal, ObjectId, Position, false, false);
public bool TryGetObject(uint objectId, out PluginNavigationObject value)
{
value = default;
return false;
}
public PluginNavigationCommandStatus SetMovementIntent(
in PluginMovementIntent intent) => PluginNavigationCommandStatus.Accepted;
public PluginNavigationCommandStatus ClearMovementIntent() =>
PluginNavigationCommandStatus.Accepted;
}
private sealed class Logger : IPluginLogger
{
public void Info(string message) { }
public void Warn(string message) { }
public void Error(string message, Exception? exception = null) { }
}
private sealed class State : IGameState
{
public IReadOnlyList<WorldEntitySnapshot> Entities => [];
}
private sealed class Events : IEvents
{
public event Action<WorldEntitySnapshot> EntitySpawned
{
add { }
remove { }
}
public event Action<double> Tick
{
add { }
remove { }
}
}
private sealed class Selection : ISelectionService
{
public uint? SelectedObjectId => null;
public uint? PreviousObjectId => null;
public event Action<SelectionChangedEvent> Changed
{
add { }
remove { }
}
public bool Select(uint objectId) => true;
public bool Clear() => true;
}
}

View file

@ -0,0 +1,202 @@
using AcDream.Plugin.Abstractions;
using AcDream.Plugins.MossTank.Expressions;
namespace AcDream.Plugins.MossTank.Tests;
public sealed class MetaViewManagerTests
{
private const string Markup =
"<panel w=\"120\" h=\"60\" title=\"Meta\" visible=\"{WindowAvailable}\" />";
[Fact]
public void CreateReplaceDestroyAndDestroyAllOwnTheRegistrationTokens()
{
var ui = new RecordingUiRegistry();
var manager = new MetaViewManager(new StubHost(ui));
Assert.True(manager.Create("Status", Markup));
Registration first = Assert.Single(ui.Registrations);
Assert.Equal("Status", first.Descriptor.Title);
Assert.True(first.Descriptor.ShowInSidePanel);
Assert.True(manager.Create("Status", Markup));
Assert.True(first.Disposed);
Assert.Equal(1, manager.Count);
Assert.True(manager.Destroy("Status"));
Assert.True(ui.Registrations[^1].Disposed);
Assert.False(manager.Destroy("Status"));
Assert.True(manager.Create("One", Markup));
Assert.True(manager.Create("Two", Markup));
manager.DestroyAll();
Assert.Equal(0, manager.Count);
Assert.All(ui.Registrations[^2..], static registration =>
Assert.True(registration.Disposed));
}
[Fact]
public void InvalidMarkupAndTheOfficialViewBoundaryArePreserved()
{
var manager = new MetaViewManager(new StubHost(new RecordingUiRegistry()));
Assert.False(manager.Create("Bad", "not xml"));
Assert.False(manager.Create("Bad", "<label />"));
for (int index = 0; index < 6; index++)
Assert.True(manager.Create($"View {index}", Markup));
Assert.Equal(6, manager.Count);
Assert.False(manager.Create("Seventh", Markup));
// VTank checks the count before duplicate replacement too.
Assert.False(manager.Create("View 0", Markup));
}
[Fact]
public void StatusHudReusesOneShelfWindowAndOwnsItsLifetime()
{
var ui = new RecordingUiRegistry();
var manager = new StatusHudManager(new StubHost(ui));
Assert.True(manager.Update("State", "Hunting"));
Registration registration = Assert.Single(ui.Registrations);
Assert.Equal("VTank Meta Status", registration.Descriptor.Title);
Assert.True(registration.Descriptor.ShowInSidePanel);
Assert.Equal(["State: Hunting"], manager.Rows);
Assert.Equal([0xE8DEC3u], manager.RowColors);
Assert.True(manager.Update("State", "Resting", 0x00FF00u));
Assert.True(manager.Update("Target", "Drudge", 0xFF9900u));
Assert.Single(ui.Registrations);
Assert.Equal(["State: Resting", "Target: Drudge"], manager.Rows);
Assert.Equal([0x00FF00u, 0xFF9900u], manager.RowColors);
manager.Destroy();
Assert.True(registration.Disposed);
Assert.Equal(0, manager.Count);
Assert.Empty(manager.Rows);
Assert.Empty(manager.RowColors);
}
[Fact]
public void UtilityBeltUiExpressionsUseThePluginScopedViewRegistry()
{
var ui = new RecordingUiRegistry();
using var expressions = new MossTankExpressionRuntime(new StubHost(ui));
Assert.True(expressions.Evaluate("uiviewexists['Status']").IsTruthy);
Assert.True(expressions.Evaluate("uiviewvisible['Status']").IsTruthy);
Assert.True(expressions.Evaluate(
"uisetlabel[uigetcontrol['Status','Action'],'Run']").IsTruthy);
Assert.Equal("Run", ui.Label);
Assert.True(expressions.Evaluate(
"uisetvisible[uigetcontrol['Status','Action'],0]").IsTruthy);
Assert.False(ui.ControlVisible);
}
private sealed class StubHost(IUiRegistry ui) : IPluginHost
{
public bool HasUi => true;
public IPluginLogger Log { get; } = new StubLogger();
public IGameState State { get; } = new StubState();
public IEvents Events { get; } = new StubEvents();
public ISelectionService Selection { get; } = new StubSelection();
public IUiRegistry Ui { get; } = ui;
public IAutomationSurface Automation => NoOpAutomationSurface.Instance;
}
private sealed class RecordingUiRegistry : IUiRegistry
{
public List<Registration> Registrations { get; } = [];
public string Label { get; private set; } = string.Empty;
public bool ControlVisible { get; private set; } = true;
public void AddMarkupPanel(string markupPath, object binding)
{
}
public IDisposable RegisterPanelContent(
PluginPanelDescriptor descriptor,
string markupContent,
object binding)
{
var registration = new Registration(descriptor, markupContent);
Registrations.Add(registration);
return registration;
}
public bool ViewExists(string viewName) => viewName == "Status";
public bool IsViewVisible(string viewName) => viewName == "Status";
public bool ControlExists(string viewName, string controlName) =>
viewName == "Status" && controlName == "Action";
public bool SetControlLabel(
string viewName,
string controlName,
string label)
{
if (!ControlExists(viewName, controlName))
return false;
Label = label;
return true;
}
public bool SetControlVisible(
string viewName,
string controlName,
bool visible)
{
if (!ControlExists(viewName, controlName))
return false;
ControlVisible = visible;
return true;
}
}
private sealed class Registration(
PluginPanelDescriptor descriptor,
string markup) : IDisposable
{
public PluginPanelDescriptor Descriptor { get; } = descriptor;
public string Markup { get; } = markup;
public bool Disposed { get; private set; }
public void Dispose() => Disposed = true;
}
private sealed class StubLogger : IPluginLogger
{
public void Info(string message) { }
public void Warn(string message) { }
public void Error(string message, Exception? exception = null) { }
}
private sealed class StubState : IGameState
{
public IReadOnlyList<WorldEntitySnapshot> Entities => [];
}
private sealed class StubEvents : IEvents
{
public event Action<WorldEntitySnapshot> EntitySpawned
{
add { }
remove { }
}
public event Action<double> Tick
{
add { }
remove { }
}
}
private sealed class StubSelection : ISelectionService
{
public uint? SelectedObjectId => null;
public uint? PreviousObjectId => null;
public event Action<SelectionChangedEvent> Changed
{
add { }
remove { }
}
public bool Select(uint objectId) => false;
public bool Clear() => false;
}
}

View file

@ -0,0 +1,164 @@
namespace AcDream.Plugins.MossTank.Tests;
public sealed class MonsterExpressionTests
{
[Theory]
[InlineData("range>5", true)]
[InlineData("range<=5", false)]
[InlineData("range>5 && species==drudge", true)]
[InlineData("range>5 && species==drudge && name!=drudge ravener", true)]
[InlineData("hasshield && metastate==hunting", true)]
[InlineData("typeid==1234", true)]
[InlineData("maxhp>=450", true)]
public void DocumentedVariablesAndOperatorsEvaluateVerbatim(
string source,
bool expected)
{
MonsterExpression expression = MonsterExpression.Compile(source);
Assert.True(expression.TryEvaluate(
Context(),
out MonsterValue value,
out string? error),
error);
Assert.Equal(MonsterValueKind.Boolean, value.Kind);
Assert.Equal(expected, value.Boolean);
}
[Fact]
public void BareTextMatchesTheMonsterNameCaseInsensitively()
{
MonsterExpression expression = MonsterExpression.Compile("Drudge Ravener");
Assert.True(expression.IsMatch(Context(name: "drudge ravener"), out _));
Assert.False(expression.IsMatch(Context(name: "Drudge Lurker"), out _));
}
[Fact]
public void RegexUsesLeftAsInputAndRightAsPattern()
{
MonsterExpression expression = MonsterExpression.Compile(
"name#^drudge .\\+er$");
Assert.True(expression.IsMatch(Context(), out string? error), error);
}
[Fact]
public void EscapedOperatorAndDigitRemainLiteralStringCharacters()
{
MonsterExpression expression = MonsterExpression.Compile(
"name==Prototype \\#\\2");
Assert.True(expression.IsMatch(Context(name: "Prototype #2"), out _));
Assert.Throws<MonsterExpressionException>(() =>
MonsterExpression.Compile("name==Prototype 2"));
}
[Fact]
public void UsesVtankDocumentedNonstandardPrecedence()
{
MonsterExpression subtraction = MonsterExpression.Compile("10-3+1==6");
MonsterExpression modulo = MonsterExpression.Compile("20/6%4==10");
Assert.True(subtraction.IsMatch(Context(), out _));
Assert.True(modulo.IsMatch(Context(), out _));
}
[Fact]
public void BooleanOperatorsShortCircuitInvalidRightBranch()
{
MonsterExpression expression = MonsterExpression.Compile(
"false && 1/0==0");
Assert.True(expression.TryEvaluate(
Context(),
out MonsterValue value,
out string? error),
error);
Assert.False(value.Boolean);
}
[Fact]
public void SettingNamesAreCaseSensitiveAndMakeExpressionDynamic()
{
var settings = new Dictionary<string, MonsterValue>(StringComparer.Ordinal)
{
["DoJiggle"] = MonsterValue.FromBoolean(true),
};
var context = Context(setting: name =>
settings.TryGetValue(name, out MonsterValue value) ? value : null);
MonsterExpression correct = MonsterExpression.Compile("setting_DoJiggle");
MonsterExpression wrong = MonsterExpression.Compile("setting_dojiggle");
Assert.True(correct.IsDynamic);
Assert.True(correct.IsMatch(context, out _));
Assert.False(wrong.IsMatch(context, out _));
}
[Fact]
public void TypeMismatchIsAReportedNonMatchRatherThanAPluginCrash()
{
MonsterExpression expression = MonsterExpression.Compile("name==5");
Assert.False(expression.IsMatch(Context(), out string? error));
Assert.Contains("matching operand types", error, StringComparison.Ordinal);
}
[Fact]
public void ResolverChecksRowsInOrderAfterDefaultAndCarriesAllActions()
{
var fallback = new MonsterRule("DEFAULT", 0);
var first = new MonsterRule(
"species==drudge",
new MonsterRuleActions
{
Priority = 3,
Flags = MonsterActionFlags.Imperil | MonsterActionFlags.Attack,
DamageType = MonsterDamageType.Fire,
ExtraVulnerability = MonsterDamageType.Fire,
WeaponObjectId = 0x70000001u,
OffhandObjectId = 0x70000002u,
PetDamageType = MonsterDamageType.Cold,
});
var later = new MonsterRule("range>1", 4);
ResolvedMonsterRule resolved = MonsterRuleResolver.Resolve(
[fallback, first, later],
Context());
Assert.Same(first, resolved.Rule);
Assert.Equal(3, resolved.Priority);
Assert.Equal(
MonsterActionFlags.Imperil | MonsterActionFlags.Attack,
resolved.Actions.Flags);
Assert.Equal(MonsterDamageType.Fire, resolved.Actions.DamageType);
Assert.Equal(0x70000001u, resolved.Actions.WeaponObjectId);
Assert.Equal(0x70000002u, resolved.Actions.OffhandObjectId);
Assert.Equal(MonsterDamageType.Cold, resolved.Actions.PetDamageType);
}
[Fact]
public void ResolverFallsBackToDefaultAndClampsPriority()
{
var fallback = new MonsterRule("DEFAULT", 99);
ResolvedMonsterRule resolved = MonsterRuleResolver.Resolve(
[fallback, new MonsterRule("species==olthoi", 4)],
Context());
Assert.Same(fallback, resolved.Rule);
Assert.Equal(4, resolved.Priority);
}
private static MonsterExpressionContext Context(
string name = "Drudge Lurker",
Func<string, MonsterValue?>? setting = null) => new(
name,
TypeId: 1234u,
Species: "Drudge",
MaximumHealth: 450,
Range: 8f,
HasShield: true,
MetaState: "Hunting",
Setting: setting);
}

View file

@ -0,0 +1,262 @@
using System.Reflection;
using System.Xml.Linq;
using AcDream.Plugin.Abstractions;
namespace AcDream.Plugins.MossTank.Tests;
public sealed class MossTankMarkupContractTests
{
[Fact]
public void VtankTabOrderAndEveryBindingResolveAgainstTheLivePanel()
{
XDocument document = XDocument.Load(
Path.Combine(AppContext.BaseDirectory, "mosstank.xml"));
XElement root = Assert.IsType<XElement>(document.Root);
Assert.Equal(
[
"Options", "Profiles", "Vitals", "Monsters", "Items",
"Consumables", "Buffs", "Route", "Meta",
],
root.Elements("tab")
.Select(static tab => (string?)tab.Attribute("text")));
PropertyInfo[] properties = typeof(MossTankPanel).GetProperties(
BindingFlags.Instance | BindingFlags.Public);
var byName = properties.ToDictionary(
static property => property.Name,
StringComparer.Ordinal);
foreach (XAttribute attribute in root.DescendantsAndSelf().Attributes())
{
string value = attribute.Value;
if (!value.Contains('{', StringComparison.Ordinal))
continue;
Assert.Matches("^\\{[^{}]+\\}$", value);
string name = value[1..^1];
Assert.True(
byName.ContainsKey(name),
$"Markup binding {value} on <{attribute.Parent?.Name}> has no "
+ $"public MossTankPanel property.");
}
}
[Fact]
public void EveryInteractiveBindingMatchesTheRetainedUiDelegateShape()
{
XDocument document = XDocument.Load(
Path.Combine(AppContext.BaseDirectory, "mosstank.xml"));
XElement root = Assert.IsType<XElement>(document.Root);
PropertyInfo[] properties = typeof(MossTankPanel).GetProperties(
BindingFlags.Instance | BindingFlags.Public);
var byName = properties.ToDictionary(
static property => property.Name,
StringComparer.Ordinal);
foreach (XElement element in root.DescendantsAndSelf())
{
AssertBindingType(element, "onclick", typeof(Action), byName);
AssertBindingType(
element,
"onsubmit",
typeof(Action<string>),
byName);
Type? changeType = element.Name.LocalName switch
{
"field" or "menu" => typeof(Action<string>),
"slider" => typeof(Action<float>),
"list" => typeof(Action<int>),
_ => null,
};
if (changeType is not null)
AssertBindingType(element, "onchange", changeType, byName);
}
}
[Fact]
public void EveryInteractiveControlDeclaresARealHandlerBinding()
{
XDocument document = XDocument.Load(
Path.Combine(AppContext.BaseDirectory, "mosstank.xml"));
XElement root = Assert.IsType<XElement>(document.Root);
HashSet<string> interactive = new(
[
"tab", "button", "toggle", "slider", "field", "menu", "list",
], StringComparer.Ordinal);
XElement[] controls = root.Descendants()
.Where(element => interactive.Contains(element.Name.LocalName))
.ToArray();
Assert.Equal(190, controls.Length);
foreach (XElement control in controls)
{
XAttribute? handler = control.Attribute("onclick")
?? control.Attribute("onchange")
?? control.Attribute("onsubmit");
Assert.NotNull(handler);
Assert.NotEqual("false", (string?)control.Attribute("enabled"));
}
}
[Fact]
public void EveryVtankTabIsBackedByALivePanelSurface()
{
var panel = new MossTankPanel(new StubHost());
Assert.True(panel.OptionsTabEnabled);
Assert.True(panel.VitalsTabEnabled);
Assert.True(panel.MonstersTabEnabled);
Assert.True(panel.BuffsTabEnabled);
Assert.True(panel.ProfilesTabEnabled);
Assert.True(panel.ItemsTabEnabled);
Assert.True(panel.ConsumablesTabEnabled);
Assert.True(panel.RouteTabEnabled);
Assert.True(panel.MetaTabEnabled);
}
[Fact]
public void AuthoredShellFitsTheMinimumCanvasAndEverySizedChildFitsItsParent()
{
XDocument document = XDocument.Load(
Path.Combine(AppContext.BaseDirectory, "mosstank.xml"));
XElement root = Assert.IsType<XElement>(document.Root);
Assert.Equal(800f, Number(root, "w"));
Assert.Equal(244f, Number(root, "h"));
AssertWithinParent(root);
}
[Fact]
public void TextlessAndAbbreviatedControlsHaveAccessibleRetailTooltips()
{
XDocument document = XDocument.Load(
Path.Combine(AppContext.BaseDirectory, "mosstank.xml"));
XElement root = Assert.IsType<XElement>(document.Root);
string[] interactive =
[
"tab", "button", "toggle", "slider", "field", "menu", "list",
];
HashSet<string> terse = new(
[
"+", "-", "↑", "↓", "F", "B", "G", "I", "Y", "V", "A",
"R", "S", "W", "FC", "Cp", "DC", "Cs",
], StringComparer.Ordinal);
foreach (XElement element in root.Descendants()
.Where(element => interactive.Contains(
element.Name.LocalName,
StringComparer.Ordinal)))
{
string? text = (string?)element.Attribute("text");
if (!string.IsNullOrWhiteSpace(text) && !terse.Contains(text))
continue;
Assert.False(
string.IsNullOrWhiteSpace((string?)element.Attribute("tooltip")),
$"<{element.Name}> text='{text}' needs a tooltip.");
}
}
private static void AssertBindingType(
XElement element,
string attributeName,
Type expectedType,
IReadOnlyDictionary<string, PropertyInfo> properties)
{
string? expression = (string?)element.Attribute(attributeName);
if (expression is null)
return;
Assert.StartsWith("{", expression, StringComparison.Ordinal);
Assert.EndsWith("}", expression, StringComparison.Ordinal);
string name = expression[1..^1];
Assert.True(
properties.TryGetValue(name, out PropertyInfo? property),
$"Markup binding {expression} on <{element.Name}> has no public "
+ "MossTankPanel property.");
Assert.Equal(expectedType, property.PropertyType);
}
private static void AssertWithinParent(XElement parent)
{
float parentWidth = Number(parent, "w");
float parentHeight = Number(parent, "h");
foreach (XElement child in parent.Elements())
{
float width = Number(child, "w");
float height = Number(child, "h");
if (width > 0f)
{
Assert.True(
Number(child, "x") + width <= parentWidth,
$"<{child.Name}> crosses the right edge of <{parent.Name}>.");
}
if (height > 0f)
{
Assert.True(
Number(child, "y") + height <= parentHeight,
$"<{child.Name}> crosses the bottom edge of <{parent.Name}>.");
}
AssertWithinParent(child);
}
}
private static float Number(XElement element, string attribute) =>
float.TryParse(
(string?)element.Attribute(attribute),
System.Globalization.NumberStyles.Float,
System.Globalization.CultureInfo.InvariantCulture,
out float value)
? value
: 0f;
private sealed class StubHost : IPluginHost
{
public bool HasUi => false;
public IPluginLogger Log { get; } = new StubLogger();
public IGameState State { get; } = new StubState();
public IEvents Events { get; } = new StubEvents();
public ISelectionService Selection { get; } = new StubSelection();
public IUiRegistry Ui => NoOpUiRegistry.Instance;
public IAutomationSurface Automation => NoOpAutomationSurface.Instance;
}
private sealed class StubLogger : IPluginLogger
{
public void Info(string message) { }
public void Warn(string message) { }
public void Error(string message, Exception? exception = null) { }
}
private sealed class StubState : IGameState
{
public IReadOnlyList<WorldEntitySnapshot> Entities => [];
}
private sealed class StubEvents : IEvents
{
public event Action<WorldEntitySnapshot> EntitySpawned
{
add { }
remove { }
}
public event Action<double> Tick
{
add { }
remove { }
}
}
private sealed class StubSelection : ISelectionService
{
public uint? SelectedObjectId => null;
public uint? PreviousObjectId => null;
public event Action<SelectionChangedEvent> Changed
{
add { }
remove { }
}
public bool Select(uint objectId) => false;
public bool Clear() => false;
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,623 @@
using AcDream.Plugin.Abstractions;
namespace AcDream.Plugins.MossTank.Tests;
public sealed class NavigationTests
{
[Theory]
[InlineData(0d, 1d, 0f)]
[InlineData(1d, 0d, 90f)]
[InlineData(0d, -1d, 180f)]
[InlineData(-1d, 0d, 270f)]
public void DesiredHeadingUsesVtankCompassConvention(
double eastWest,
double northSouth,
float expected)
{
PluginNavigationPosition origin = Position(0d, 0d);
PluginNavigationPosition target = Position(eastWest, northSouth);
Assert.Equal(expected, NavigationController.DesiredHeading(origin, target));
}
[Theory]
[InlineData(350f, 10f, 20f)]
[InlineData(10f, 350f, -20f)]
[InlineData(90f, 270f, 180f)]
public void SignedHeadingDeltaChoosesShortestRetailTurn(
float current,
float desired,
float expected) =>
Assert.Equal(expected, NavigationController.SignedHeadingDelta(current, desired));
[Fact]
public void PointSteeringTurnsInPlaceOutsideFortyFiveDegrees()
{
var automation = new FakeAutomation
{
NavigationSnapshot = Snapshot(Position(0d, 0d, heading: 0f)),
};
NavigationController controller = Controller(
automation,
RouteMode.Circular,
Waypoint(RouteWaypointType.Point, Position(1d, 0d)));
Assert.True(controller.Tick(0.05d, canAct: true));
PluginMovementIntent intent = Assert.Single(automation.Intents);
Assert.False(intent.Forward);
Assert.True(intent.TurnRight);
Assert.False(intent.TurnLeft);
}
[Fact]
public void PointSteeringMovesWhileTurningInsideFarFortyFiveDegreeCone()
{
var automation = new FakeAutomation
{
NavigationSnapshot = Snapshot(Position(0d, 0d, heading: 60f)),
};
NavigationController controller = Controller(
automation,
RouteMode.Circular,
Waypoint(RouteWaypointType.Point, Position(1d, 0d)));
Assert.True(controller.Tick(0.05d, canAct: true));
PluginMovementIntent intent = Assert.Single(automation.Intents);
Assert.True(intent.Forward);
Assert.True(intent.TurnRight);
}
[Fact]
public void CircularRouteWrapsAndOnceRouteStops()
{
RouteWaypoint first = Waypoint(RouteWaypointType.Point, Position(0d, 0d));
RouteWaypoint second = Waypoint(RouteWaypointType.Point, Position(1d, 0d));
var circularAutomation = new FakeAutomation
{
NavigationSnapshot = Snapshot(first.Position),
};
NavigationController circular = Controller(
circularAutomation,
RouteMode.Circular,
first,
second);
Assert.True(circular.Tick(0.05d, canAct: true));
Assert.Equal(1, circular.CurrentWaypointIndex);
circularAutomation.NavigationSnapshot = Snapshot(second.Position);
Assert.True(circular.Tick(0.05d, canAct: true));
Assert.Equal(0, circular.CurrentWaypointIndex);
var onceAutomation = new FakeAutomation
{
NavigationSnapshot = Snapshot(first.Position),
};
NavigationController once = Controller(
onceAutomation,
RouteMode.Once,
first);
Assert.True(once.Tick(0.05d, canAct: true));
Assert.False(once.Tick(0.05d, canAct: true));
Assert.Equal("Once route complete.", once.Status);
}
[Fact]
public void FollowReadsMovingTargetAndHoldsAtMinimumDistance()
{
var automation = new FakeAutomation
{
NavigationSnapshot = Snapshot(Position(0d, 0d, heading: 90f)),
};
automation.Objects[7u] = new PluginNavigationObject(
7u,
"Leader",
Position(1d, 0d));
var settings = new NavigationSettings
{
Enabled = true,
Mode = RouteMode.Target,
MinimumDistanceMeters = 2d,
FollowTargetObjectId = 7u,
};
var controller = new NavigationController(new FakeHost(automation), settings);
Assert.True(controller.Tick(0.05d, canAct: true));
Assert.True(Assert.Single(automation.Intents).Forward);
automation.Objects[7u] = new PluginNavigationObject(
7u,
"Leader",
Position(0.005d, 0d));
Assert.False(controller.Tick(0.05d, canAct: true));
Assert.Equal(1, automation.ClearCount);
Assert.Contains("holding", controller.Status, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void FollowAroundCornersUsesOldestUnreachedBreadcrumb()
{
var automation = new FakeAutomation
{
NavigationSnapshot = Snapshot(Position(0d, 0d, heading: 90f)),
};
automation.Objects[7u] = new PluginNavigationObject(
7u,
"Leader",
Position(0.1d, 0d));
var settings = new NavigationSettings
{
Enabled = true,
Mode = RouteMode.Target,
MinimumDistanceMeters = 2d,
FollowTargetObjectId = 7u,
FollowAroundCorners = true,
};
var controller = new NavigationController(new FakeHost(automation), settings);
Assert.True(controller.Tick(0.05d, canAct: true));
automation.Objects[7u] = new PluginNavigationObject(
7u,
"Leader",
Position(0.1d, 0.1d));
automation.Intents.Clear();
Assert.True(controller.Tick(0.05d, canAct: true));
PluginMovementIntent intent = Assert.Single(automation.Intents);
Assert.True(intent.Forward);
Assert.False(intent.TurnLeft);
Assert.False(intent.TurnRight);
}
[Fact]
public void CheckpointWaitsForServerAcceptedPosition()
{
PluginNavigationPosition point = Position(0d, 0d);
var automation = new FakeAutomation
{
NavigationSnapshot = Snapshot(point) with
{
ConfirmedPosition = Position(0.1d, 0d),
ConfirmedPositionRevision = 4UL,
},
};
NavigationController controller = Controller(
automation,
RouteMode.Once,
Waypoint(RouteWaypointType.Checkpoint, point));
Assert.True(controller.Tick(1d, canAct: true));
Assert.Contains("waiting for server", controller.Status, StringComparison.OrdinalIgnoreCase);
Assert.True(controller.Tick(14d, canAct: true));
Assert.True(Assert.Single(automation.Intents).Forward);
automation.NavigationSnapshot = Snapshot(point) with
{
ConfirmedPosition = point,
ConfirmedPositionRevision = 5UL,
};
Assert.True(controller.Tick(0.05d, canAct: true));
Assert.False(controller.Tick(0.05d, canAct: true));
}
[Fact]
public void ClosedDoorPausesRouteAndUsesCanonicalItemAction()
{
var automation = new FakeAutomation
{
NavigationSnapshot = Snapshot(Position(0d, 0d, heading: 90f)),
};
automation.WorldObjects.Add(new PluginNavigationObject(
55u,
"Dungeon Door",
Position(0.01d, 0d))
{
IsDoor = true,
IsOpen = false,
HasLockState = true,
});
var settings = new NavigationSettings
{
Enabled = true,
OpenDoors = true,
Mode = RouteMode.Circular,
};
settings.Waypoints.Add(Waypoint(
RouteWaypointType.Point,
Position(1d, 0d)));
var controller = new NavigationController(new FakeHost(automation), settings);
Assert.True(controller.Tick(0.05d, canAct: true));
Assert.Equal([55u], automation.UsedObjects);
Assert.Empty(automation.Intents);
automation.WorldObjects[0] = automation.WorldObjects[0] with
{
IsOpen = true,
};
Assert.True(controller.Tick(0.05d, canAct: true));
Assert.True(Assert.Single(automation.Intents).Forward);
}
[Fact]
public void PauseAndChatActionsObserveOfficialInitialDelay()
{
var automation = new FakeAutomation
{
NavigationSnapshot = Snapshot(Position(0d, 0d)),
};
RouteWaypoint pause = Waypoint(RouteWaypointType.Pause, Position(0d, 0d));
pause.DurationMilliseconds = 100;
RouteWaypoint chat = Waypoint(RouteWaypointType.ChatCommand, Position(0d, 0d));
chat.Text = "/say route";
NavigationController controller = Controller(
automation,
RouteMode.Once,
pause,
chat);
Assert.True(controller.Tick(0.05d, canAct: true));
Assert.True(controller.Tick(0.05d, canAct: true));
Assert.Equal(0, controller.CurrentWaypointIndex);
Assert.True(controller.Tick(0.19d, canAct: true));
Assert.Empty(automation.SubmittedChat);
Assert.True(controller.Tick(0.01d, canAct: true));
Assert.Equal(["/say route"], automation.SubmittedChat);
Assert.False(controller.Tick(0.01d, canAct: true));
}
[Fact]
public void PortalWaypointWaitsForPortalExitRatherThanUseDispatch()
{
var automation = new FakeAutomation
{
NavigationSnapshot = Snapshot(Position(0d, 0d)),
ItemCompletion = new PluginItemUseCompletion(4, 10u, 0u, 0u),
};
RouteWaypoint use = Waypoint(RouteWaypointType.Portal, Position(0d, 0d));
use.ObjectId = 77u;
use.ObjectName = "Town Crier";
NavigationController controller = Controller(automation, RouteMode.Once, use);
Assert.True(controller.Tick(0.05d, canAct: true));
Assert.Equal([77u], automation.UsedObjects);
Assert.True(controller.Tick(0.05d, canAct: true));
automation.ItemCompletion = new PluginItemUseCompletion(5, 77u, 0u, 0u);
Assert.True(controller.Tick(0.05d, canAct: true));
automation.NavigationSnapshot = automation.NavigationSnapshot with
{
IsPortalSpace = true,
};
Assert.True(controller.Tick(0.05d, canAct: true));
automation.NavigationSnapshot = Snapshot(Position(0.1d, 0d));
Assert.True(controller.Tick(0.05d, canAct: true));
Assert.False(controller.Tick(0.05d, canAct: true));
}
[Fact]
public void UseNpcRepeatsUntilTheNpcRespondsInChat()
{
var automation = new FakeAutomation
{
NavigationSnapshot = Snapshot(Position(0d, 0d)),
FoundObject = new PluginNavigationObject(
91u,
"Town Crier",
Position(0.01d, 0d)),
};
RouteWaypoint use = Waypoint(RouteWaypointType.UseNpc, Position(0d, 0d));
use.ObjectName = "Town Crier";
NavigationController controller = Controller(automation, RouteMode.Once, use);
Assert.True(controller.Tick(0.05d, canAct: true));
automation.ChatMessages.Add(new PluginChatMessage(
1UL,
91u,
3,
"Town Crier",
"Town Crier tells you, Welcome.",
string.Empty));
Assert.True(controller.Tick(0.05d, canAct: true));
Assert.False(controller.Tick(0.05d, canAct: true));
}
[Fact]
public void NamedNpcWaypointReacquiresChangedObjectId()
{
var automation = new FakeAutomation
{
NavigationSnapshot = Snapshot(Position(0d, 0d)),
FoundObject = new PluginNavigationObject(
91u,
"Town Crier",
Position(0.01d, 0d)),
};
RouteWaypoint use = Waypoint(RouteWaypointType.UseNpc, Position(0d, 0d));
use.ObjectId = 77u;
use.ObjectName = "Town Crier";
NavigationController controller = Controller(automation, RouteMode.Once, use);
Assert.True(controller.Tick(0.05d, canAct: true));
Assert.Equal(91u, use.ObjectId);
Assert.Equal([91u], automation.UsedObjects);
Assert.Equal("Town Crier", automation.FindName);
}
[Fact]
public void JumpAlignsBeforeChargingAndWaitsForLanding()
{
var automation = new FakeAutomation
{
NavigationSnapshot = Snapshot(Position(0d, 0d, heading: 0f)),
};
RouteWaypoint jump = Waypoint(RouteWaypointType.Jump, Position(0d, 0d));
jump.JumpHeadingDegrees = 90f;
jump.JumpChargeMilliseconds = 100;
NavigationController controller = Controller(automation, RouteMode.Once, jump);
Assert.True(controller.Tick(0.05d, canAct: true));
PluginMovementIntent turn = Assert.Single(automation.Intents);
Assert.True(turn.TurnRight);
Assert.False(turn.Jump);
automation.NavigationSnapshot = Snapshot(Position(0d, 0d, heading: 90f));
Assert.True(controller.Tick(0.05d, canAct: true));
Assert.True(automation.Intents[^1].Jump);
Assert.True(controller.Tick(0.05d, canAct: true));
Assert.False(automation.Intents[^1].Jump);
automation.NavigationSnapshot = Snapshot(
Position(0d, 0d, heading: 90f),
airborne: true);
Assert.True(controller.Tick(0.05d, canAct: true));
automation.NavigationSnapshot = Snapshot(Position(0d, 0d, heading: 90f));
Assert.True(controller.Tick(0.25d, canAct: true));
Assert.False(controller.Tick(0.01d, canAct: true));
}
[Fact]
public void RouteProfilesRoundTripEveryWaypointField()
{
var storage = new MemoryStorage();
var host = new FakeHost(new FakeAutomation(), storage);
var source = new NavigationSettings
{
Enabled = true,
Priority = true,
Mode = RouteMode.Linear,
MinimumDistanceMeters = 4.5d,
FollowTargetObjectId = 99u,
FollowTargetName = "Leader",
FollowAroundCorners = false,
OpenDoors = true,
DoorIdentifyRangeMeters = 35d,
DoorOpenRangeMeters = 3.5d,
DoorLockpickExcessThreshold = 17,
};
source.Waypoints.Add(new RouteWaypoint
{
Type = RouteWaypointType.Jump,
Position = new PluginNavigationPosition(0x7F7F0001u, 1.2d, -3.4d, 5.6d, 78f, true),
ObjectId = 88u,
ObjectName = "Portal",
Text = "/say hello",
DurationMilliseconds = 1234,
Recall = RouteRecallKind.SecondaryPortal,
JumpHeadingDegrees = 271.5f,
JumpRun = true,
JumpChargeMilliseconds = 875,
JumpDirection = RouteJumpDirection.StrafeRight,
});
var first = new MossTankRouteProfileStore(host);
Assert.True(first.BindCharacter("Test Character"));
first.SaveCurrent(source);
var target = new NavigationSettings();
var second = new MossTankRouteProfileStore(host);
Assert.True(second.BindCharacter("Test Character"));
Assert.True(second.LoadCurrent(target));
Assert.True(target.Enabled);
Assert.True(target.Priority);
Assert.Equal(RouteMode.Linear, target.Mode);
Assert.Equal(4.5d, target.MinimumDistanceMeters);
Assert.Equal(99u, target.FollowTargetObjectId);
Assert.False(target.FollowAroundCorners);
Assert.True(target.OpenDoors);
Assert.Equal(35d, target.DoorIdentifyRangeMeters);
Assert.Equal(3.5d, target.DoorOpenRangeMeters);
Assert.Equal(17, target.DoorLockpickExcessThreshold);
RouteWaypoint waypoint = Assert.Single(target.Waypoints);
Assert.Equal(RouteWaypointType.Jump, waypoint.Type);
Assert.Equal(271.5f, waypoint.JumpHeadingDegrees);
Assert.True(waypoint.JumpRun);
Assert.Equal(875, waypoint.JumpChargeMilliseconds);
Assert.Equal(RouteJumpDirection.StrafeRight, waypoint.JumpDirection);
Assert.Equal(0x7F7F0001u, waypoint.Position.CellId);
}
private static NavigationController Controller(
FakeAutomation automation,
RouteMode mode,
params RouteWaypoint[] waypoints)
{
var settings = new NavigationSettings
{
Enabled = true,
Mode = mode,
MinimumDistanceMeters = 2d,
};
settings.Waypoints.AddRange(waypoints);
return new NavigationController(new FakeHost(automation), settings);
}
private static RouteWaypoint Waypoint(
RouteWaypointType type,
PluginNavigationPosition position) => new()
{
Type = type,
Position = position,
};
private static PluginNavigationSnapshot Snapshot(
PluginNavigationPosition position,
bool airborne = false) => new(
IsAvailable: true,
IsPortalSpace: false,
LocalObjectId: 1u,
position,
IsMoving: false,
IsAirborne: airborne);
private static PluginNavigationPosition Position(
double eastWest,
double northSouth,
float heading = 0f) => new(
0x7F7F0001u,
eastWest,
northSouth,
0d,
heading,
IsOutdoor: true);
private sealed class FakeHost(
FakeAutomation automation,
IPluginStorage? storage = null) : IPluginHost
{
public bool HasUi => false;
public IPluginLogger Log { get; } = new FakeLogger();
public IGameState State { get; } = new FakeState();
public IEvents Events { get; } = new FakeEvents();
public ISelectionService Selection { get; } = new FakeSelection();
public IUiRegistry Ui => NoOpUiRegistry.Instance;
public IPluginStorage Storage { get; } = storage ?? NoOpPluginStorage.Instance;
public IAutomationSurface Automation { get; } = automation;
}
private sealed class FakeAutomation
: IAutomationSurface, INavigationAutomation, IPluginChat, IItemAutomation
{
public bool IsAvailable => true;
public ICharacterInfo Character => NoOpAutomationSurface.Instance;
public ISpellCatalog Spells => NoOpAutomationSurface.Instance;
public IMagicCommands Magic => NoOpAutomationSurface.Instance;
public IPluginChat Chat => this;
public IItemAutomation Items => this;
public INavigationAutomation Navigation => this;
public PluginNavigationSnapshot NavigationSnapshot { get; set; }
PluginNavigationSnapshot INavigationAutomation.Snapshot => NavigationSnapshot;
public Dictionary<uint, PluginNavigationObject> Objects { get; } = [];
public List<PluginNavigationObject> WorldObjects { get; } = [];
public List<PluginMovementIntent> Intents { get; } = [];
public List<string> SubmittedChat { get; } = [];
public List<PluginChatMessage> ChatMessages { get; } = [];
public List<uint> UsedObjects { get; } = [];
public int ClearCount { get; private set; }
public PluginItemUseCompletion ItemCompletion { get; set; }
public PluginItemUseCompletion LastCompletion => ItemCompletion;
public PluginNavigationObject? FoundObject { get; set; }
public string? FindName { get; private set; }
public bool TryGetObject(uint objectId, out PluginNavigationObject value) =>
Objects.TryGetValue(objectId, out value);
public bool TryFindObject(
string name,
in PluginNavigationPosition near,
double maximumDistanceMeters,
out PluginNavigationObject value)
{
FindName = name;
value = FoundObject ?? default;
return FoundObject.HasValue;
}
public IReadOnlyList<PluginNavigationObject> CaptureObjects() =>
WorldObjects;
public PluginNavigationCommandStatus SetMovementIntent(
in PluginMovementIntent intent)
{
Intents.Add(intent);
return PluginNavigationCommandStatus.Accepted;
}
public PluginNavigationCommandStatus ClearMovementIntent()
{
ClearCount++;
return PluginNavigationCommandStatus.Accepted;
}
public bool Submit(string text)
{
SubmittedChat.Add(text);
return true;
}
public IReadOnlyList<PluginChatMessage> CaptureMessages(ulong afterSequence) =>
ChatMessages.Where(message => message.Sequence > afterSequence).ToArray();
public void PostSystemMessage(string text) { }
public PluginItemCommandResult Use(uint objectId)
{
UsedObjects.Add(objectId);
return new PluginItemCommandResult(PluginItemCommandStatus.Started);
}
}
private sealed class MemoryStorage : IPluginStorage
{
private readonly Dictionary<string, string> _text = new(StringComparer.Ordinal);
public bool IsAvailable => true;
public string? ReadText(string key) =>
_text.TryGetValue(key, out string? value) ? value : null;
public void WriteText(string key, string content) => _text[key] = content;
public bool Delete(string key) => _text.Remove(key);
}
private sealed class FakeLogger : IPluginLogger
{
public void Info(string message) { }
public void Warn(string message) { }
public void Error(string message, Exception? exception = null) { }
}
private sealed class FakeState : IGameState
{
public IReadOnlyList<WorldEntitySnapshot> Entities => [];
}
private sealed class FakeEvents : IEvents
{
public event Action<WorldEntitySnapshot> EntitySpawned
{
add { }
remove { }
}
public event Action<double> Tick
{
add { }
remove { }
}
}
private sealed class FakeSelection : ISelectionService
{
public uint? SelectedObjectId => null;
public uint? PreviousObjectId => null;
public event Action<SelectionChangedEvent> Changed
{
add { }
remove { }
}
public bool Select(uint objectId) => false;
public bool Clear() => false;
}
}

View file

@ -0,0 +1,283 @@
using AcDream.Plugin.Abstractions;
namespace AcDream.Plugins.MossTank.Tests;
public sealed class PetAutomationTests
{
[Fact]
public void Select_UsesHighestPriorityTargetsElementAndDensityRange()
{
var settings = Settings(MonsterDamageType.PlayerAuto, MonsterDamageType.Fire);
settings.PetRangeMode = PetRangeMode.Custom;
settings.PetCustomRange = 8f;
settings.PetMonsterDensity = 2;
settings.Rules.Insert(0, new MonsterRule(
"name#Boss",
new MonsterRuleActions
{
Priority = 4,
DamageType = MonsterDamageType.Fire,
PetDamageType = MonsterDamageType.PlayerAuto,
}));
PluginInventoryItem cold = Device(1, 49387, mastery: 3, level: 100);
PluginInventoryItem fire = Device(2, 49380, mastery: 3, level: 100);
PetAutomationChoice choice = PetAutomation.Select(
[cold, fire],
[Target(10, "Trash", 4), Target(11, "Boss", 7), Target(12, "Far", 9)],
new Character(mastery: 3, summoning: 300),
settings,
activeOwnedPetCount: 0,
allowRefill: true,
allowSummon: true);
Assert.Equal(PetAutomationActionKind.Summon, choice.Kind);
Assert.Equal(2u, choice.Device.ObjectId);
Assert.Equal(11u, choice.Target.ObjectId);
Assert.Equal(MonsterDamageType.Fire, choice.DamageType);
}
[Fact]
public void Select_RejectsWrongMasteryAndInsufficientSummoningSkill()
{
var settings = Settings(MonsterDamageType.Auto);
PluginInventoryItem wrongMastery = Device(1, 49380, mastery: 2, level: 50);
PluginInventoryItem tooDifficult = Device(2, 49387, mastery: 3, level: 400);
PetAutomationChoice choice = PetAutomation.Select(
[wrongMastery, tooDifficult],
[Target(10, "Target", 4)],
new Character(mastery: 3, summoning: 300),
settings,
0,
allowRefill: true,
allowSummon: true);
Assert.Equal(PetAutomationActionKind.None, choice.Kind);
}
[Fact]
public void Select_ExplicitElementNeverFallsBackButPlayerAutoDoes()
{
PluginInventoryItem cold = Device(1, 49387, mastery: 3, level: 100);
var explicitFire = Settings(MonsterDamageType.Fire);
var playerAuto = Settings(
MonsterDamageType.PlayerAuto,
MonsterDamageType.Fire);
var character = new Character(mastery: 3, summoning: 300);
PluginCombatTarget[] targets = [Target(10, "Target", 4)];
Assert.Equal(PetAutomationActionKind.None, PetAutomation.Select(
[cold], targets, character, explicitFire, 0, true, true).Kind);
Assert.Equal(PetAutomationActionKind.Summon, PetAutomation.Select(
[cold], targets, character, playerAuto, 0, true, true).Kind);
}
[Fact]
public void Select_RefillsChosenDeviceWithEncapsulatedSpiritBeforeSummon()
{
var settings = Settings(MonsterDamageType.Cold);
settings.PetRefillCountNormal = 5;
PluginInventoryItem device = Device(
1, 49387, mastery: 3, level: 100, structure: 5, maximum: 50);
PluginInventoryItem spirit = Item(
2, PetDeviceCatalog.EncapsulatedSpiritWeenieClassId);
PetAutomationChoice choice = PetAutomation.Select(
[device, spirit],
[Target(10, "Target", 4)],
new Character(mastery: 3, summoning: 300),
settings,
0,
allowRefill: true,
allowSummon: true);
Assert.Equal(PetAutomationActionKind.Refill, choice.Kind);
Assert.Equal(spirit.ObjectId, choice.Tool.ObjectId);
Assert.Equal(device.ObjectId, choice.Device.ObjectId);
}
[Fact]
public void Select_DoesNothingWhileOwnedCombatPetIsActive()
{
var settings = Settings(MonsterDamageType.Cold);
PetAutomationChoice choice = PetAutomation.Select(
[Device(1, 49387, 3, 100)],
[Target(10, "Target", 4)],
new Character(3, 300),
settings,
activeOwnedPetCount: 1,
allowRefill: true,
allowSummon: true);
Assert.Equal(PetAutomationActionKind.None, choice.Kind);
}
[Fact]
public void Tick_WaitsForUseDoneAndHonorsRetailCooldown()
{
var items = new ItemAutomation
{
Items = [Device(1, 49387, 3, 100)],
};
var automation = new PetAutomation();
var settings = Settings(MonsterDamageType.Cold);
var character = new Character(3, 300);
PluginCombatTarget[] targets = [Target(10, "Target", 4)];
Assert.True(automation.Tick(
items, character, targets, settings, 1d, out _));
Assert.Equal(new[] { 1u }, items.Uses);
Assert.True(automation.Tick(
items, character, targets, settings, 1.1d, out string pending));
Assert.Contains("Summoning", pending, StringComparison.Ordinal);
Assert.Single(items.Uses);
items.Completion = new PluginItemUseCompletion(1, 1, 0, 0);
Assert.False(automation.Tick(
items, character, targets, settings, 1.2d, out _));
Assert.Single(items.Uses);
Assert.False(automation.Tick(
items, character, targets, settings, 46.1d, out _));
Assert.Single(items.Uses);
Assert.True(automation.Tick(
items, character, targets, settings, 46.2d, out _));
Assert.Equal(2, items.Uses.Count);
}
[Theory]
[InlineData(48886u, (int)MonsterDamageType.Bludgeon)]
[InlineData(49366u, (int)MonsterDamageType.Acid)]
[InlineData(49380u, (int)MonsterDamageType.Fire)]
[InlineData(49387u, (int)MonsterDamageType.Cold)]
[InlineData(49373u, (int)MonsterDamageType.Electric)]
public void Catalog_MapsRetailDeviceWcids(
uint wcid,
int expected)
=> Assert.Equal(
(MonsterDamageType)expected,
PetDeviceCatalog.DamageType(wcid));
private static CombatSettings Settings(
MonsterDamageType pet,
MonsterDamageType attack = MonsterDamageType.Auto)
{
var settings = new CombatSettings { SummonPets = true };
settings.Rules.Clear();
settings.Rules.Add(new MonsterRule(
"DEFAULT",
new MonsterRuleActions
{
DamageType = attack,
PetDamageType = pet,
}));
for (uint id = 1; id <= 20; id++)
settings.CombatItemObjectIds.Add(id);
return settings;
}
private static PluginCombatTarget Target(uint id, string name, float distance) =>
new(id, name, 1, distance, 0, true, 1f);
private static PluginInventoryItem Device(
uint id,
uint wcid,
int mastery,
int level,
int structure = 50,
int maximum = 50) =>
Item(id, wcid) with
{
PetClass = 49000,
SummoningMastery = mastery,
UseRequiresSkill = 54,
UseRequiresSkillLevel = level,
Structure = structure,
MaximumStructure = maximum,
};
private static PluginInventoryItem Item(uint id, uint wcid) => new(
ObjectId: id,
WeenieClassId: wcid,
Name: $"Item {id}",
ItemType: 0,
ContainerObjectId: 1,
WielderObjectId: 0,
ValidLocations: 0,
EquippedLocation: 0,
Useability: 0,
TargetType: 0,
PublicFlags: 0,
StackSize: 1,
Structure: 1,
MaximumStructure: 1,
SpellId: 0,
PetClass: 0,
SummoningMastery: 0,
ProcSpellId: 0,
ProcSpellSelfTargeted: false,
ProcSpellRate: 0,
WeaponSkill: 0,
DamageType: 0,
Damage: 0,
DamageVariance: 0,
UseRequiresSkill: 0,
UseRequiresSkillLevel: 0,
UseRequiresSkillSpecialized: 0);
private sealed class Character(int mastery, uint summoning) : ICharacterInfo
{
public bool IsInWorld => true;
public uint ObjectId => 1;
public uint CurrentHealth => 100;
public uint MaxHealth => 100;
public uint CurrentStamina => 100;
public uint MaxStamina => 100;
public uint CurrentMana => 100;
public uint MaxMana => 100;
public int SummoningMastery => mastery;
public IReadOnlyList<PluginSkillInfo> Skills =>
[new(54, "Summoning", PluginSkillTraining.Trained, summoning)];
public IReadOnlyList<PluginAttributeInfo> Attributes => [];
public IReadOnlyList<PluginActiveEnchantment> ActiveEnchantments => [];
public bool TryGetSkill(uint skillId, out PluginSkillInfo skill)
{
if (skillId == 54)
{
skill = Skills[0];
return true;
}
skill = default;
return false;
}
}
private sealed class ItemAutomation : IItemAutomation
{
public bool IsAvailable => true;
public bool IsBusy { get; set; }
public int ActiveOwnedPetCount { get; set; }
public PluginItemUseCompletion Completion { get; set; }
public PluginItemUseCompletion LastCompletion => Completion;
public IReadOnlyList<PluginInventoryItem> Items { get; set; } = [];
public List<uint> Uses { get; } = [];
public List<(uint Source, uint Target)> Applies { get; } = [];
public IReadOnlyList<PluginInventoryItem> CaptureOwnedItems() => Items;
public PluginItemCommandResult Use(uint objectId)
{
Uses.Add(objectId);
return new(PluginItemCommandStatus.Started);
}
public PluginItemCommandResult Apply(uint objectId, uint targetObjectId)
{
Applies.Add((objectId, targetObjectId));
return new(PluginItemCommandStatus.Started);
}
}
}

View file

@ -0,0 +1,230 @@
using AcDream.Plugin.Abstractions;
namespace AcDream.Plugins.MossTank.Tests;
public sealed class ProfileGiveControllerTests
{
[Fact]
public void NamedProfileGivesOnlyKeepMatchesAndWaitsForCompletion()
{
var automation = new FakeAutomation
{
ObjectsValue =
[
new PluginWorldObject(
100u, 0u, "Mule", PluginObjectClass.Player,
0u, 0u, 0u),
],
ItemsValue =
[
Item(10u, "Trade Pyreal"),
Item(11u, "Personal Note"),
],
};
var storage = new MemoryStorage();
var host = new FakeHost(automation, storage);
var profiles = new MossTankLootProfileStore(host);
profiles.BindCharacter(automation.Name);
Assert.True(profiles.Create("Mule Items", false, [], out _));
profiles.SaveCurrent(
[
new LootRule
{
Expression = "name ~= trade",
Action = LootAction.Keep,
},
new LootRule
{
Expression = "*",
Action = LootAction.NoLoot,
},
]);
var controller = new ProfileGiveController(host, profiles);
Assert.True(controller.TryStart("Mule Items.utl", "Mule"));
Assert.True(controller.Tick(0.1d, canAct: true));
Assert.Equal([(10u, 100u, 0u)], automation.Gives);
// A second tick cannot submit another command until the server's
// inventory completion advances for the exact source object.
Assert.True(controller.Tick(0.1d, canAct: true));
Assert.Single(automation.Gives);
automation.Completion = new PluginInventoryCompletion(
1,
PluginInventoryCommandKind.Give,
10u,
0u);
Assert.True(controller.Tick(0.1d, canAct: true));
Assert.False(controller.Tick(0.1d, canAct: true));
Assert.False(controller.IsRunning);
Assert.Contains("1 item(s)", controller.Status, StringComparison.Ordinal);
}
[Fact]
public void StartRejectsBusyMissingProfileAndMissingTarget()
{
var automation = new FakeAutomation
{
ObjectsValue =
[
new PluginWorldObject(
100u, 0u, "Mule", PluginObjectClass.Npc,
0u, 0u, 0u),
],
};
var storage = new MemoryStorage();
var host = new FakeHost(automation, storage);
var profiles = new MossTankLootProfileStore(host);
profiles.BindCharacter(automation.Name);
var controller = new ProfileGiveController(host, profiles);
Assert.False(controller.TryStart("Missing", "Mule"));
Assert.True(profiles.Create("Empty", false, [], out _));
Assert.False(controller.TryStart("Empty", "Missing"));
Assert.True(controller.TryStart("Empty", "Mule"));
Assert.False(controller.TryStart("Empty", "Mule"));
}
private static PluginInventoryItem Item(uint id, string name) => new(
id, 0u, name, 0u, 1u, 0u, 0u, 0u, 0u, 0u, 0u,
1, 0, 0, 0u, 0, 0, 0u, false, 0d, 0, 0, 0, 0d, 0, 0, 0);
private sealed class FakeAutomation
: IAutomationSurface, ICharacterInfo, IItemAutomation,
IWorldObjectAutomation
{
public bool IsAvailable { get; set; } = true;
public ICharacterInfo Character => this;
public ISpellCatalog Spells => NoOpAutomationSurface.Instance;
public IMagicCommands Magic => NoOpAutomationSurface.Instance;
public IPluginChat Chat => NoOpAutomationSurface.Instance;
public IItemAutomation Items => this;
public IWorldObjectAutomation Objects => this;
public bool IsInWorld => IsAvailable;
public string Name => "Tester";
public uint ObjectId => 1u;
public uint CurrentHealth => 0u;
public uint MaxHealth => 0u;
public uint CurrentStamina => 0u;
public uint MaxStamina => 0u;
public uint CurrentMana => 0u;
public uint MaxMana => 0u;
public IReadOnlyList<PluginSkillInfo> Skills => [];
public IReadOnlyList<PluginAttributeInfo> Attributes => [];
public IReadOnlyList<PluginActiveEnchantment> ActiveEnchantments => [];
public IReadOnlyList<PluginInventoryItem> ItemsValue { get; set; } = [];
public IReadOnlyList<PluginWorldObject> ObjectsValue { get; set; } = [];
public PluginInventoryCompletion Completion { get; set; }
public List<(uint Item, uint Target, uint Amount)> Gives { get; } = [];
bool IItemAutomation.IsAvailable => true;
bool IItemAutomation.IsBusy => false;
bool IWorldObjectAutomation.IsAvailable => true;
public PluginInventoryCompletion LastInventoryCompletion => Completion;
public IReadOnlyList<PluginInventoryItem> CaptureOwnedItems() => ItemsValue;
public IReadOnlyList<PluginWorldObject> CaptureObjects() => ObjectsValue;
public bool TryGet(uint objectId, out PluginWorldObject value)
{
foreach (PluginWorldObject item in ObjectsValue)
{
if (item.ObjectId == objectId)
{
value = item;
return true;
}
}
value = default;
return false;
}
public bool TryCaptureProperties(
uint objectId,
out PluginItemProperties properties)
{
properties = new PluginItemProperties(
new Dictionary<uint, int>(),
new Dictionary<uint, long>(),
new Dictionary<uint, bool>(),
new Dictionary<uint, double>(),
new Dictionary<uint, string>(),
new Dictionary<uint, uint>(),
new Dictionary<uint, uint>());
return true;
}
public PluginItemCommandResult Give(
uint objectId,
uint targetObjectId,
uint amount = 0u)
{
Gives.Add((objectId, targetObjectId, amount));
return new PluginItemCommandResult(PluginItemCommandStatus.Started);
}
public bool TryGetSkill(uint skillId, out PluginSkillInfo skill)
{
skill = default;
return false;
}
}
private sealed class FakeHost(
FakeAutomation automation,
IPluginStorage storage) : IPluginHost
{
public bool HasUi => false;
public IPluginLogger Log { get; } = new FakeLogger();
public IGameState State { get; } = new FakeState();
public IEvents Events { get; } = new FakeEvents();
public ISelectionService Selection { get; } = new FakeSelection();
public IUiRegistry Ui => NoOpUiRegistry.Instance;
public IPluginStorage Storage => storage;
public IAutomationSurface Automation => automation;
}
private sealed class MemoryStorage : IPluginStorage
{
private readonly Dictionary<string, string> _text =
new(StringComparer.Ordinal);
public bool IsAvailable => true;
public string? ReadText(string key) =>
_text.TryGetValue(key, out string? value) ? value : null;
public void WriteText(string key, string content) => _text[key] = content;
public bool Delete(string key) => _text.Remove(key);
}
private sealed class FakeLogger : IPluginLogger
{
public void Info(string message) { }
public void Warn(string message) { }
public void Error(string message, Exception? exception = null) { }
}
private sealed class FakeState : IGameState
{
public IReadOnlyList<WorldEntitySnapshot> Entities => [];
}
private sealed class FakeEvents : IEvents
{
public event Action<WorldEntitySnapshot> EntitySpawned
{
add { }
remove { }
}
public event Action<double> Tick
{
add { }
remove { }
}
}
private sealed class FakeSelection : ISelectionService
{
public uint? SelectedObjectId => null;
public uint? PreviousObjectId => null;
public event Action<SelectionChangedEvent> Changed
{
add { }
remove { }
}
public bool Select(uint objectId) => false;
public bool Clear() => false;
}
}

View file

@ -0,0 +1,397 @@
using AcDream.Plugin.Abstractions;
namespace AcDream.Plugins.MossTank.Tests;
public sealed class VitalRechargeTests
{
[Fact]
public void RetailDefaultsAreExactNineThresholds()
{
var settings = new VitalSettings();
Assert.Equal(0.75, settings.NormalHealth);
Assert.Equal(0.50, settings.NormalStamina);
Assert.Equal(0.50, settings.NormalMana);
Assert.Equal(0.01, settings.NoTargetHealth);
Assert.Equal(0.01, settings.NoTargetStamina);
Assert.Equal(0.01, settings.NoTargetMana);
Assert.Equal(0.20, settings.HelperHealth);
Assert.Equal(0.01, settings.HelperStamina);
Assert.Equal(0.01, settings.HelperMana);
}
[Fact]
public void NoTargetTopoffExtendsRatherThanReplacesCombatThreshold()
{
var settings = new VitalSettings { NoTargetHealth = 0.90 };
var surface = new Surface { CurrentHealth = 80 };
Assert.Null(VitalPlan.DecideNeed(surface, settings, noTarget: false));
Assert.Equal(
VitalKind.Health,
VitalPlan.DecideNeed(surface, settings, noTarget: true));
}
[Fact]
public void MagicHealthHandlerOrderMatchesOfficialDefaultTable()
{
Assert.Equal(
[
VitalRechargeMethod.StaminaToHealth,
VitalRechargeMethod.ManaToHealth,
VitalRechargeMethod.RegularSpell,
VitalRechargeMethod.Food,
VitalRechargeMethod.Kit,
],
VitalRechargePlanner.Handlers(VitalKind.Health, true, 15));
Assert.Equal(
[
VitalRechargeMethod.Kit,
VitalRechargeMethod.StaminaToHealth,
VitalRechargeMethod.ManaToHealth,
VitalRechargeMethod.RegularSpell,
VitalRechargeMethod.Food,
],
VitalRechargePlanner.Handlers(VitalKind.Health, true, 16));
}
[Fact]
public void NonMagicHealthConversionExistsOnlyInOfficialEmergencyBand()
{
Assert.Equal(
[
VitalRechargeMethod.Food,
VitalRechargeMethod.Kit,
VitalRechargeMethod.StaminaToHealth,
VitalRechargeMethod.RegularSpell,
],
VitalRechargePlanner.Handlers(VitalKind.Health, false, 10));
Assert.Equal(
[
VitalRechargeMethod.Food,
VitalRechargeMethod.Kit,
VitalRechargeMethod.RegularSpell,
],
VitalRechargePlanner.Handlers(VitalKind.Health, false, 15));
Assert.Equal(
[
VitalRechargeMethod.Kit,
VitalRechargeMethod.Food,
VitalRechargeMethod.RegularSpell,
],
VitalRechargePlanner.Handlers(VitalKind.Health, false, 16));
}
[Fact]
public void RechargeHandlerSetCanOverrideOneStanceVitalBand()
{
const string profile =
"combat-health-critical=Regular Spell > Kit Recharge; "
+ "magic-mana-normal=Recharge With Food > Regular Spell";
Assert.Equal(
[VitalRechargeMethod.RegularSpell, VitalRechargeMethod.Kit],
VitalRechargePlanner.Handlers(
VitalKind.Health,
false,
5,
profile));
Assert.Equal(
[VitalRechargeMethod.Food, VitalRechargeMethod.RegularSpell],
VitalRechargePlanner.Handlers(
VitalKind.Mana,
true,
50,
profile));
}
[Fact]
public void RechargeBoostAdjustmentTemporarilyRaisesNeed()
{
var surface = new Surface { CurrentHealth = 80 };
var settings = new VitalSettings { NormalHealth = 0.75 };
Assert.Null(VitalPlan.DecideNeed(surface, settings, noTarget: false));
Assert.Equal(
VitalKind.Health,
VitalPlan.DecideNeed(
surface,
settings,
noTarget: false,
healthCurrentAdjustment: 40));
}
[Fact]
public void IncantationHealthAliasParticipatesInRegularSpellHandler()
{
var surface = new Surface
{
Mode = PluginCombatMode.Magic,
CurrentHealth = 50,
Spells =
[
Spell(
(uint)SpellId.AdjaSIntervention,
"Adja's Intervention",
1u,
400),
],
};
Assert.True(VitalRechargePlanner.TryPlan(
VitalKind.Health,
surface,
new VitalSettings { MinimumHealKitSuccessChance = 100 },
new CombatSettings(),
out VitalRechargeChoice choice));
Assert.Equal((uint)SpellId.AdjaSIntervention, choice.SpellId);
}
[Fact]
public void MagicModeUsesProfiledViableKitBeforeRegularHealAboveEmergencyBand()
{
var surface = new Surface
{
Mode = PluginCombatMode.Magic,
CurrentHealth = 50,
Skills = [Skill(21u, 400u)],
Items = [Kit(10u, "Plentiful Healing Kit", booster: 2)],
};
var combat = new CombatSettings();
combat.ConsumableNames.Add("Plentiful Healing Kit");
surface.Spells = [Spell(100u, "Heal Self VII", family: 1u, quality: 300)];
Assert.True(VitalRechargePlanner.TryPlan(
VitalKind.Health,
surface,
new VitalSettings(),
combat,
out VitalRechargeChoice choice));
Assert.Equal(VitalRechargeSourceKind.Kit, choice.SourceKind);
Assert.Equal(10u, choice.ItemObjectId);
}
[Fact]
public void EmergencyMagicHealthPrefersStaminaConversionBeforeKit()
{
var surface = new Surface
{
Mode = PluginCombatMode.Magic,
CurrentHealth = 10,
Skills = [Skill(21u, 400u), Skill(33u, 400u)],
Items = [Kit(10u, "Plentiful Healing Kit", booster: 2)],
Spells =
[
Spell(101u, "Stamina to Health Self VII", 5u, 300),
Spell(102u, "Heal Self VII", 1u, 300),
],
};
var combat = new CombatSettings();
combat.ConsumableNames.Add("Plentiful Healing Kit");
Assert.True(VitalRechargePlanner.TryPlan(
VitalKind.Health,
surface,
new VitalSettings(),
combat,
out VitalRechargeChoice choice));
Assert.Equal(VitalRechargeSourceKind.LearnedSpell, choice.SourceKind);
Assert.Equal(101u, choice.SpellId);
}
[Fact]
public void DirectLearnedSpellWinsFinalQualityTieAgainstCasterItem()
{
PluginSpellInfo learned = Spell(101u, "Heal Self VII", 1u, 300);
PluginSpellInfo itemSpell = Spell(102u, "Heal Self VII", 1u, 300);
var surface = new Surface
{
Mode = PluginCombatMode.Magic,
CurrentHealth = 50,
Spells = [learned],
Lookup = [itemSpell],
Items = [Caster(20u, "Healing Lens", itemSpell.SpellId)],
};
var combat = new CombatSettings();
combat.CombatItemNames.Add("Healing Lens");
Assert.True(VitalRechargePlanner.TryPlan(
VitalKind.Health,
surface,
new VitalSettings { MinimumHealKitSuccessChance = 100 },
combat,
out VitalRechargeChoice choice));
Assert.Equal(VitalRechargeSourceKind.LearnedSpell, choice.SourceKind);
Assert.Equal(learned.SpellId, choice.SpellId);
}
[Fact]
public void HelperChoosesLowestInRangeFellowAndStrongestFamilySpell()
{
PluginSpellInfo basis = Spell(
(uint)SpellId.AdjaSGift,
"Adja's Gift",
900u,
100);
PluginSpellInfo strong = Spell(300u, "Adja's Grace", 900u, 350);
var surface = new Surface
{
Mode = PluginCombatMode.Magic,
Spells = [strong],
Lookup = [basis],
InFellowship = true,
Fellows =
[
Fellow(70u, "Near", health: 19, distance: 10f),
Fellow(71u, "Lowest", health: 5, distance: 20f),
Fellow(72u, "Out of range", health: 1, distance: 100f),
],
};
Assert.True(VitalRechargePlanner.TryPlanHelper(
surface,
new VitalSettings(),
out VitalRechargeChoice choice));
Assert.Equal(VitalKind.Health, choice.Vital);
Assert.Equal(71u, choice.TargetObjectId);
Assert.Equal(strong.SpellId, choice.SpellId);
}
[Fact]
public void HealKitChanceUsesRetailLogisticDifficultyFormula()
{
var surface = new Surface { CurrentHealth = 50 };
double chance = VitalRechargePlanner.HealKitChance(
90u,
10,
surface,
VitalKind.Health,
PluginCombatMode.Peace);
Assert.Equal(0.5d, chance, precision: 10);
}
private static PluginSkillInfo Skill(uint id, uint current) =>
new(id, string.Empty, PluginSkillTraining.Trained, current);
private static PluginSpellInfo Spell(
uint id,
string name,
uint family,
int quality) =>
new(id, name, family, 7, quality, 10, 0f, 33u, string.Empty, true, true);
private static PluginInventoryItem Kit(
uint id,
string name,
int booster) => Item(id, name) with
{
BoosterVital = booster,
BoostValue = 0,
HealKitModifier = 1.2,
};
private static PluginInventoryItem Caster(
uint id,
string name,
uint spellId) => new(
id, 1u, name, 0x8000u, 1u, 0u, 0u, 0u, 0u, 0u, 0u,
1, 0, 0, spellId, 0, 0, 0u, false, 0d, 0, 0, 0, 0d,
0, 0, 0);
private static PluginInventoryItem Item(uint id, string name) => new(
id, 1u, name, 0x80u, 1u, 0u, 0u, 0u, 0u, 0u, 0u,
1, 1, 1, 0u, 0, 0, 0u, false, 0d, 0, 0, 0, 0d,
21, 0, 0);
private static PluginFellowMember Fellow(
uint id,
string name,
uint health,
float distance) => new(
id, name, health, 100u, 100u, 100u, 100u, 100u, distance);
private sealed class Surface :
IAutomationSurface,
ICharacterInfo,
ISpellCatalog,
IMagicCommands,
IPluginChat,
ICombatAutomation,
IItemAutomation,
IFellowshipAutomation
{
public bool IsAvailable => true;
public ICharacterInfo Character => this;
public ISpellCatalog SpellsCatalog => this;
ISpellCatalog IAutomationSurface.Spells => this;
public IMagicCommands Magic => this;
public IPluginChat Chat => this;
public ICombatAutomation Combat => this;
public IItemAutomation ItemsAutomation => this;
IItemAutomation IAutomationSurface.Items => this;
public IFellowshipAutomation Fellowship => this;
public bool IsInWorld => true;
public uint ObjectId => 1u;
public uint CurrentHealth { get; init; } = 100u;
public uint MaxHealth { get; init; } = 100u;
public uint CurrentStamina { get; init; } = 100u;
public uint MaxStamina { get; init; } = 100u;
public uint CurrentMana { get; init; } = 100u;
public uint MaxMana { get; init; } = 100u;
public IReadOnlyList<PluginSkillInfo> Skills { get; init; } = [];
public IReadOnlyList<PluginAttributeInfo> Attributes => [];
public IReadOnlyList<PluginActiveEnchantment> ActiveEnchantments => [];
public IReadOnlyList<PluginSpellInfo> Spells { get; set; } = [];
public IReadOnlyList<PluginSpellInfo> Lookup { get; init; } = [];
public IReadOnlyList<PluginSpellInfo> KnownSelfBuffs => Spells;
public IReadOnlyList<PluginInventoryItem> Items { get; init; } = [];
public bool InFellowship { get; init; }
public IReadOnlyList<PluginFellowMember> Fellows { get; init; } = [];
public PluginCombatMode Mode { get; init; } = PluginCombatMode.Peace;
public PluginCombatSnapshot Snapshot => new(0u, Mode, default, 0f, 0f,
false, false, false, false);
bool IItemAutomation.IsAvailable => true;
bool IFellowshipAutomation.IsInFellowship => InFellowship;
public bool IsCasting => false;
public bool TryGetSkill(uint skillId, out PluginSkillInfo skill)
{
foreach (PluginSkillInfo candidate in Skills)
{
if (candidate.SkillId == skillId)
{
skill = candidate;
return true;
}
}
skill = default;
return false;
}
public bool TryGet(uint spellId, out PluginSpellInfo info)
{
foreach (PluginSpellInfo candidate in Spells.Concat(Lookup))
{
if (candidate.SpellId == spellId)
{
info = candidate;
return true;
}
}
info = default;
return false;
}
public IReadOnlyList<PluginInventoryItem> CaptureOwnedItems() => Items;
public IReadOnlyList<PluginFellowMember> CaptureMembers() => Fellows;
public IReadOnlyList<PluginCombatTarget> CaptureHostileTargets(float maximumDistance) => [];
public PluginCombatCommandResult EnterDefaultMode() => new(PluginCombatCommandStatus.AlreadyReady);
public PluginCombatCommandResult BeginPhysicalAttack(uint targetObjectId, PluginAttackHeight height, float power) => new(PluginCombatCommandStatus.Started);
public PluginCombatCommandResult ReleasePhysicalAttack() => new(PluginCombatCommandStatus.Released);
public PluginCombatCommandResult AbortPhysicalAttack() => new(PluginCombatCommandStatus.Stopped);
public PluginCastGate EvaluateGate(uint spellId) => PluginCastGate.Ready;
public bool Cast(uint spellId) => true;
public void PostSystemMessage(string text) { }
}
}

View file

@ -0,0 +1,134 @@
using AcDream.Plugin.Abstractions;
namespace AcDream.Plugins.MossTank.Tests;
public sealed class VtankAmmunitionDatabaseTests
{
[Fact]
public void LoadsCompleteOfficialGameInfoTable()
{
Assert.Equal(120, VtankAmmunitionDatabase.Options.Count);
Assert.Equal(5, VtankAmmunitionDatabase.LauncherType(0x001u));
Assert.Equal(6, VtankAmmunitionDatabase.LauncherType(0x080u));
Assert.Equal(7, VtankAmmunitionDatabase.LauncherType(0x020u));
}
[Fact]
public void SpecialMaskSelectsRaiderOnlyWhenEnabled()
{
var character = new Character(
[
new PluginSkillInfo(
47u,
"Missile Weapons",
PluginSkillTraining.Trained,
300u)
{
Base = 300u,
},
]);
VtankAmmunitionOption regular = Assert.IsType<VtankAmmunitionOption>(
VtankAmmunitionDatabase.Select(
5,
MonsterDamageType.Electric,
VtankPrismaticAmmoPolicy.NoPrismatic,
enabledSpecialMask: 0,
character,
static _ => true));
VtankAmmunitionOption raider = Assert.IsType<VtankAmmunitionOption>(
VtankAmmunitionDatabase.Select(
5,
MonsterDamageType.Electric,
VtankPrismaticAmmoPolicy.NoPrismatic,
enabledSpecialMask: 1,
character,
static _ => true));
Assert.Equal("Deadly Lightning Arrow", regular.Name);
Assert.Equal("Raider Lightning Arrow", raider.Name);
}
[Fact]
public void PrismaticAmmoRequiresBothOfficialSkills()
{
var missileOnly = new Character(
[
new PluginSkillInfo(
47u,
"Missile Weapons",
PluginSkillTraining.Trained,
400u)
{
Base = 400u,
},
]);
var both = new Character(
[
new PluginSkillInfo(
47u,
"Missile Weapons",
PluginSkillTraining.Trained,
400u)
{
Base = 400u,
},
new PluginSkillInfo(
37u,
"Fletching",
PluginSkillTraining.Trained,
400u),
]);
VtankAmmunitionOption withoutFletching =
Assert.IsType<VtankAmmunitionOption>(
VtankAmmunitionDatabase.Select(
5,
MonsterDamageType.Fire,
VtankPrismaticAmmoPolicy.ForcePrismatic,
0,
missileOnly,
static _ => true));
VtankAmmunitionOption withFletching =
Assert.IsType<VtankAmmunitionOption>(
VtankAmmunitionDatabase.Select(
5,
MonsterDamageType.Fire,
VtankPrismaticAmmoPolicy.ForcePrismatic,
0,
both,
static _ => true));
Assert.Equal("Deadly Fire Arrow", withoutFletching.Name);
Assert.Equal("Deadly Prismatic Arrow", withFletching.Name);
}
private sealed class Character(IReadOnlyList<PluginSkillInfo> skills)
: ICharacterInfo
{
public bool IsInWorld => true;
public uint ObjectId => 1u;
public uint CurrentHealth => 100u;
public uint MaxHealth => 100u;
public uint CurrentStamina => 100u;
public uint MaxStamina => 100u;
public uint CurrentMana => 100u;
public uint MaxMana => 100u;
public IReadOnlyList<PluginSkillInfo> Skills => skills;
public IReadOnlyList<PluginAttributeInfo> Attributes => [];
public IReadOnlyList<PluginActiveEnchantment> ActiveEnchantments => [];
public bool TryGetSkill(uint skillId, out PluginSkillInfo skill)
{
foreach (PluginSkillInfo value in skills)
{
if (value.SkillId != skillId)
continue;
skill = value;
return true;
}
skill = default;
return false;
}
}
}

View file

@ -0,0 +1,58 @@
using AcDream.Plugin.Abstractions;
namespace AcDream.Plugins.MossTank.Tests;
public sealed class VtankDamageDatabaseTests
{
[Fact]
public void ExactMonsterOverrideWinsOverSpecies()
{
PluginCombatTarget target = Target("Magma Golem", 1);
Assert.Equal(
[
MonsterDamageType.Cold,
MonsterDamageType.Bludgeon,
MonsterDamageType.Pierce,
MonsterDamageType.Slash,
],
VtankDamageDatabase.Preferences(target));
}
[Fact]
public void CreatureTypeUsesOfficialOrderedSpeciesPreference()
{
PluginCombatTarget target = Target("Some Olthoi", 1);
Assert.Equal(MonsterDamageType.Bludgeon,
VtankDamageDatabase.Preferences(target)[0]);
Assert.True(
VtankDamageDatabase.PreferenceIndex(
target,
MonsterDamageType.Bludgeon)
< VtankDamageDatabase.PreferenceIndex(
target,
MonsterDamageType.Fire));
}
[Fact]
public void UnknownTargetUsesVtankFinalFallbackOrder()
{
PluginCombatTarget target = Target("New Server Creature", 0);
Assert.Equal(
[
MonsterDamageType.Pierce,
MonsterDamageType.Bludgeon,
MonsterDamageType.Slash,
MonsterDamageType.Acid,
MonsterDamageType.Electric,
MonsterDamageType.Cold,
MonsterDamageType.Fire,
],
VtankDamageDatabase.Preferences(target));
}
private static PluginCombatTarget Target(string name, int species) =>
new(10, name, 100, 5, 0, true, 1f) { SpeciesId = species };
}

View file

@ -0,0 +1,118 @@
namespace AcDream.Plugins.MossTank.Tests;
public sealed class VtankLootProfileSerializerTests
{
[Fact]
public void ReadsAndWritesExactUtlOneRecordsAndUnknownBlocks()
{
const string source = "UTL\r\n1\r\n1\r\n"
+ "Epic loot\r\nLegacy editor text\r\n42;10;1;3;9999\r\n7\r\n"
+ "12\r\n^Epic.*\r\n1\r\n11\r\n25000\r\n19\r\n"
+ "7\r\nfalse\r\n"
+ "SalvageCombine\r\n36\r\n1\r\n1-6, 7-8, 9, 10\r\n1\r\n61\r\n1-10\r\n0\r\n"
+ "FutureBlock\r\n7\r\nhello\r\n";
Assert.True(VtankLootProfileSerializer.TryRead(
source,
out VtankLootProfile profile,
out string error), error);
LootRule rule = Assert.Single(profile.Rules);
Assert.Equal("Epic loot", rule.Name);
Assert.Equal("Legacy editor text", rule.CustomExpression);
Assert.Equal(LootAction.KeepUpTo, rule.Action);
Assert.Equal(7, rule.KeepCount);
Assert.Equal(42, rule.Priority);
Assert.Equal([1, 3, 9999],
rule.VtankRequirements.Select(static requirement => requirement.Type));
Assert.Equal("1-10", profile.SalvageCombine.MaterialCombineStrings[61]);
Assert.Equal("hello\r\n", Assert.Single(profile.UnknownBlocks).Payload);
string canonical = VtankLootProfileSerializer.Write(profile);
Assert.True(VtankLootProfileSerializer.TryRead(
canonical,
out VtankLootProfile second,
out error), error);
Assert.Equal(
canonical,
VtankLootProfileSerializer.Write(second));
}
[Fact]
public void RoundTripsEveryVtankRequirementTypeWithoutLosingPayload()
{
int[] types =
[
0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13,
14, 15, 16, 17,
1000, 1001, 1002, 1003, 1004,
2000, 2001, 2003, 2005, 2006, 2007, 2008,
9999,
];
var profile = new VtankLootProfile();
LootRule rule = new()
{
Name = "All vocabulary",
Action = LootAction.Keep,
Priority = -19,
};
foreach (int type in types)
{
rule.VtankRequirements.Add(new VtankLootRequirement
{
Type = type,
Payload = $"payload-{type}\r\nsecond-{type}\r\n",
});
}
profile.Rules.Add(rule);
string source = VtankLootProfileSerializer.Write(profile);
Assert.True(VtankLootProfileSerializer.TryRead(
source,
out VtankLootProfile loaded,
out string error), error);
LootRule roundTrip = Assert.Single(loaded.Rules);
Assert.Equal(types,
roundTrip.VtankRequirements.Select(static requirement => requirement.Type));
Assert.Equal(
rule.VtankRequirements.Select(static requirement => requirement.Payload),
roundTrip.VtankRequirements.Select(static requirement => requirement.Payload));
Assert.Equal(source, VtankLootProfileSerializer.Write(loaded));
}
[Fact]
public void NativeExpressionsExportDisabledInsteadOfAccidentalMatchAll()
{
var profile = new VtankLootProfile
{
Rules =
[
new LootRule
{
Name = "Native only",
Expression = "name ~= Sword && value > 100",
Action = LootAction.Keep,
},
],
};
string source = VtankLootProfileSerializer.Write(profile);
Assert.Contains("0;1;9999\r\n", source, StringComparison.Ordinal);
Assert.Contains("6\r\ntrue\r\n", source, StringComparison.Ordinal);
}
[Fact]
public void ReadsLegacyVersionZeroKnownRequirements()
{
const string source = "1\r\nLegacy\r\n7;1;1;8\r\n^Sword$\r\n1\r\n3\r\n";
Assert.True(VtankLootProfileSerializer.TryRead(
source,
out VtankLootProfile profile,
out string error), error);
LootRule rule = Assert.Single(profile.Rules);
Assert.Equal(0, profile.SourceVersion);
Assert.Equal(2, rule.VtankRequirements.Count);
Assert.Equal("^Sword$\r\n1\r\n", rule.VtankRequirements[0].Payload);
Assert.Equal("3\r\n", rule.VtankRequirements[1].Payload);
}
}

View file

@ -0,0 +1,190 @@
namespace AcDream.Plugins.MossTank.Tests;
public sealed class VtankMetaProfileSerializerTests
{
[Fact]
public void LoadsKnownTypedCondActRecord()
{
const string source = "1\r\nCondAct\r\n5\r\nCType\r\nAType\r\n"
+ "CData\r\nAData\r\nState\r\nn\r\nn\r\nn\r\nn\r\nn\r\n1\r\n"
+ "i\r\n1\r\ni\r\n2\r\ni\r\n0\r\ns\r\n/say ready\r\n"
+ "s\r\nDefault\r\n";
Assert.True(VtankMetaProfileSerializer.TryLoad(
source, out MetaProfile profile, out string error), error);
MetaRule rule = Assert.Single(profile.Rules);
Assert.Equal(MetaConditionKind.Always, rule.Condition.Kind);
Assert.Equal(MetaActionKind.ChatCommand, rule.Action.Kind);
Assert.Equal("/say ready", rule.Action.Text);
Assert.Equal("Default", rule.State);
Assert.Equal(source, VtankMetaProfileSerializer.Save(profile));
}
[Fact]
public void RoundTripPreservesEveryVtankConditionActionAndEmbeddedNav()
{
MetaCondition[] conditions = BuildConditions();
MetaAction[] actions = BuildActions();
var profile = new MetaProfile();
for (int index = 0; index < conditions.Length; index++)
{
profile.Rules.Add(new MetaRule
{
State = $"State {index}",
Condition = conditions[index],
Action = actions[index % actions.Length],
});
}
for (int index = conditions.Length; index < actions.Length; index++)
{
profile.Rules.Add(new MetaRule
{
State = $"Action {index}",
Condition = MetaCondition.Always(),
Action = actions[index],
});
}
string first = VtankMetaProfileSerializer.Save(profile);
Assert.True(VtankMetaProfileSerializer.TryLoad(
first, out MetaProfile loaded, out string error), error);
string second = VtankMetaProfileSerializer.Save(loaded);
Assert.Equal(first, second);
Assert.Equal(profile.Rules.Count, loaded.Rules.Count);
MetaCondition priority = loaded.Rules.Single(rule =>
rule.Condition.Kind == MetaConditionKind.MonsterPriorityCountWithinDistance)
.Condition;
Assert.Equal(2, priority.Number);
Assert.Equal(18.5, priority.SecondaryNumber);
Assert.Equal(7, priority.TertiaryNumber);
MetaAction embedded = loaded.Rules.Select(static rule => rule.Action)
.First(action => action.Kind == MetaActionKind.LoadEmbeddedNavigationRoute);
Assert.Equal("One point", embedded.SecondaryText);
Assert.Equal("uTank2 NAV 1.2\r\n4\r\n1\r\n0\r\n1\r\n2\r\n3\r\n0\r\n",
embedded.Text);
Assert.Contains("<view width=\"120\" />s\r\n", first, StringComparison.Ordinal);
}
[Fact]
public void DisabledNativeRuleIsNotExportedAsExecutableLegacyRule()
{
var profile = new MetaProfile
{
Rules =
[
new MetaRule
{
Enabled = false,
Condition = MetaCondition.Always(),
Action = new MetaAction
{
Kind = MetaActionKind.ChatCommand,
Text = "/say must not run",
},
},
],
};
string source = VtankMetaProfileSerializer.Save(profile);
Assert.DoesNotContain("must not run", source, StringComparison.Ordinal);
Assert.True(VtankMetaProfileSerializer.TryLoad(
source, out MetaProfile loaded, out string error), error);
Assert.Empty(loaded.Rules);
}
private static MetaCondition[] BuildConditions() =>
[
C(MetaConditionKind.Never),
C(MetaConditionKind.Always),
C(MetaConditionKind.All, children: [C(MetaConditionKind.Always)]),
C(MetaConditionKind.Any, children: [C(MetaConditionKind.Never)]),
C(MetaConditionKind.ChatMessage, "^ready$"),
C(MetaConditionKind.PackSlotsLessThanOrEqual, number: 7),
C(MetaConditionKind.SecondsInStateGreaterThanOrEqual, number: 12),
C(MetaConditionKind.NavigationRouteEmpty),
C(MetaConditionKind.CharacterDeath),
C(MetaConditionKind.AnyVendorOpen),
C(MetaConditionKind.VendorClosed),
C(MetaConditionKind.InventoryItemCountLessThanOrEqual, "Prismatic Taper", 5),
C(MetaConditionKind.InventoryItemCountGreaterThanOrEqual, "Pyreal", 10),
C(MetaConditionKind.MonsterNameCountWithinDistance, "Olthoi.*", 3, 22.25),
C(MetaConditionKind.MonsterPriorityCountWithinDistance,
number: 2, secondaryNumber: 18.5, tertiaryNumber: 7),
C(MetaConditionKind.NeedToBuff),
C(MetaConditionKind.NoMonstersWithinDistance, number: 9.5),
C(MetaConditionKind.LandblockEquals, number: unchecked((int)0x8B370000u)),
C(MetaConditionKind.LandcellEquals, number: unchecked((int)0x8B37E3A1u)),
C(MetaConditionKind.PortalspaceEntered),
C(MetaConditionKind.PortalspaceExited),
C(MetaConditionKind.Not, children: [C(MetaConditionKind.Never)]),
C(MetaConditionKind.PersistentSecondsInStateGreaterThanOrEqual, number: 30),
C(MetaConditionKind.TimeLeftOnSpellGreaterThanOrEqual,
number: 3179, secondaryNumber: 45),
C(MetaConditionKind.BurdenPercentGreaterThanOrEqual, number: 110),
C(MetaConditionKind.DistanceFromAnyRoutePointGreaterThanOrEqual, number: 13.75),
C(MetaConditionKind.Expression, "getvar['go']==1"),
C(MetaConditionKind.ChatMessageCapture, "^(?<who>.+)$", secondary: "2;4"),
];
private static MetaAction[] BuildActions() =>
[
A(MetaActionKind.None),
A(MetaActionKind.SetMetaState, "Hunt"),
A(MetaActionKind.ChatCommand, "/say hello"),
A(MetaActionKind.All, children:
[
A(MetaActionKind.ChatCommand, "/say first"),
A(MetaActionKind.ExpressionAction, "setvar['done',1]"),
]),
A(MetaActionKind.LoadEmbeddedNavigationRoute,
"uTank2 NAV 1.2\r\n4\r\n1\r\n0\r\n1\r\n2\r\n3\r\n0\r\n",
"One point"),
A(MetaActionKind.CallMetaState, "Worker", "ReturnHere"),
A(MetaActionKind.ReturnFromCall),
A(MetaActionKind.ExpressionAction, "setvar['x',2]"),
A(MetaActionKind.ChatExpression, "cstr[getvar['x']]"),
A(MetaActionKind.SetWatchdog, "Recover", number: 12.5, secondaryNumber: 4.75),
A(MetaActionKind.ClearWatchdog),
A(MetaActionKind.GetVtankOption, "OpenDoors", "doors"),
A(MetaActionKind.SetVtankOption, "OpenDoors", "istrue[1]"),
A(MetaActionKind.CreateView, "myview", "<view width=\"120\" />"),
A(MetaActionKind.DestroyView, "myview"),
A(MetaActionKind.DestroyAllViews),
];
private static MetaCondition C(
MetaConditionKind kind,
string text = "",
double number = 0,
double secondaryNumber = 0,
double tertiaryNumber = 0,
string secondary = "",
List<MetaCondition>? children = null) => new()
{
Kind = kind,
Text = text,
SecondaryText = secondary,
Number = number,
SecondaryNumber = secondaryNumber,
TertiaryNumber = tertiaryNumber,
Children = children ?? [],
};
private static MetaAction A(
MetaActionKind kind,
string text = "",
string secondary = "",
double number = 0,
double secondaryNumber = 0,
List<MetaAction>? children = null) => new()
{
Kind = kind,
Text = text,
SecondaryText = secondary,
Number = number,
SecondaryNumber = secondaryNumber,
Children = children ?? [],
};
}

View file

@ -0,0 +1,199 @@
using AcDream.Plugin.Abstractions;
namespace AcDream.Plugins.MossTank.Tests;
public sealed class VtankNavRouteSerializerTests
{
[Fact]
public void LoadsEveryOfficialNav12WaypointPayload()
{
const string nav = """
uTank2 NAV 1.2
1
10
0
12.5
-3.25
0.5
0
1
13
-4
0
0
1234
2
14
-5
0
0
987
3
15
-6
0
0
2500
4
16
-7
0
0
/say hello
5
17
-8
0
0
4321
Vendor Bob
6
18
-9
0
0
Portal to Holtburg
14
True
18.5
-9.5
1
7
19
-10
0
0
Town Crier
37
True
19.5
-10.5
2
8
20
-11
0
0
9
21
-12
0
0
180.5
True
1000.00005
""";
var settings = new NavigationSettings { Enabled = true };
bool loaded = VtankNavRouteSerializer.TryLoad(
nav,
settings,
NoOpSpellCatalog.Instance,
out string error);
Assert.True(loaded, error);
Assert.True(settings.Enabled);
Assert.Equal(RouteMode.Circular, settings.Mode);
Assert.Equal(10, settings.Waypoints.Count);
Assert.Equal(RouteWaypointType.Point, settings.Waypoints[0].Type);
Assert.Equal(12.5, settings.Waypoints[0].Position.EastWest);
Assert.Equal(1234u, settings.Waypoints[1].ObjectId);
Assert.Equal(987u, settings.Waypoints[2].RecallSpellId);
Assert.Equal(2500, settings.Waypoints[3].DurationMilliseconds);
Assert.Equal("/say hello", settings.Waypoints[4].Text);
Assert.Equal("Vendor Bob", settings.Waypoints[5].ObjectName);
Assert.Equal(18.5, settings.Waypoints[6].Position.EastWest);
Assert.Equal("Town Crier", settings.Waypoints[7].ObjectName);
Assert.Equal(14, settings.Waypoints[6].LegacyObjectClass);
Assert.Equal(37, settings.Waypoints[7].LegacyObjectClass);
Assert.True(settings.Waypoints[6].LegacyReferenceValid);
Assert.Equal(RouteWaypointType.Checkpoint, settings.Waypoints[8].Type);
Assert.Equal(180.5f, settings.Waypoints[9].JumpHeadingDegrees);
Assert.True(settings.Waypoints[9].JumpRun);
Assert.Equal(1000, settings.Waypoints[9].JumpChargeMilliseconds);
Assert.Equal(RouteJumpDirection.StrafeRight, settings.Waypoints[9].JumpDirection);
string saved = VtankNavRouteSerializer.Save(settings);
Assert.StartsWith("uTank2 NAV 1.2\r\n", saved, StringComparison.Ordinal);
Assert.Contains("1000.00005\r\n", saved, StringComparison.Ordinal);
var roundTrip = new NavigationSettings();
Assert.True(VtankNavRouteSerializer.TryLoad(
saved,
roundTrip,
NoOpSpellCatalog.Instance,
out error), error);
Assert.Equal(10, roundTrip.Waypoints.Count);
Assert.Equal(14, roundTrip.Waypoints[6].LegacyObjectClass);
Assert.Equal(RouteJumpDirection.StrafeRight,
roundTrip.Waypoints[9].JumpDirection);
Assert.Equal(1000, roundTrip.Waypoints[9].JumpChargeMilliseconds);
}
[Fact]
public void LoadsEmbeddedWrapperAndDoesNotMutateOnFailure()
{
const string wrapped = """
Route Name
1
uTank2 NAV 1.2
4
1
0
1
2
3
0
""";
var settings = new NavigationSettings();
Assert.True(VtankNavRouteSerializer.TryLoad(
wrapped,
settings,
NoOpSpellCatalog.Instance,
out _));
Assert.Equal(RouteMode.Once, settings.Mode);
Assert.Single(settings.Waypoints);
Assert.False(VtankNavRouteSerializer.TryLoad(
"broken",
settings,
NoOpSpellCatalog.Instance,
out string error));
Assert.NotEmpty(error);
Assert.Equal(RouteMode.Once, settings.Mode);
Assert.Single(settings.Waypoints);
}
[Fact]
public void TargetRoutePreservesSignedRetailObjectIdBitPattern()
{
var original = new NavigationSettings
{
Mode = RouteMode.Target,
FollowTargetName = "High-bit fellow",
FollowTargetObjectId = 0xF1234567u,
};
string saved = VtankNavRouteSerializer.Save(original);
var loaded = new NavigationSettings();
Assert.True(VtankNavRouteSerializer.TryLoad(
saved,
loaded,
NoOpSpellCatalog.Instance,
out string error), error);
Assert.Equal(RouteMode.Target, loaded.Mode);
Assert.Equal("High-bit fellow", loaded.FollowTargetName);
Assert.Equal(0xF1234567u, loaded.FollowTargetObjectId);
}
private sealed class NoOpSpellCatalog : ISpellCatalog
{
public static NoOpSpellCatalog Instance { get; } = new();
public IReadOnlyList<PluginSpellInfo> KnownSelfBuffs => [];
public bool TryGet(uint spellId, out PluginSpellInfo info)
{
info = default;
return false;
}
}
}

View file

@ -424,6 +424,63 @@ public sealed class PlayerMouseLookMovementTests
Assert.False(controller.PrepareForAttackRequest());
}
[Fact]
public void PersistentCommandRetakesTurnAfterServerPostureAcknowledgement()
{
var controller = CreateController();
var command = new MovementInput(
TurnRight: true,
IsPersistentCommand: true);
MovementResult started = controller.Update(1f / 60f, command);
Assert.Equal(MotionCommand.TurnRight, started.TurnCommand);
// The live MossTank failure: ACE acknowledges an explicit combat-mode
// change with a non-autonomous Magic + Ready movement event after the
// plugin has already begun facing its target.
controller.SetLastMoveWasAutonomous(false);
controller.Motion.MoveToInterpretedState(new InboundInterpretedState
{
CurrentStyle = 0x80000049u,
ForwardCommand = MotionCommand.Ready,
ForwardSpeed = 1f,
TurnCommand = 0u,
TurnSpeed = 1f,
});
Assert.Equal(0u, controller.Motion.InterpretedState.TurnCommand);
MovementResult resumed = controller.Update(1f / 60f, command);
Assert.True(resumed.ShouldSendMovementEvent);
Assert.Equal(
MotionCommand.TurnRight,
controller.Motion.InterpretedState.TurnCommand);
Assert.Equal(MotionCommand.TurnRight, resumed.TurnCommand);
}
[Fact]
public void PhysicalHeldKeyKeepsRetailEdgeBehaviorAfterServerPosture()
{
var controller = CreateController();
var physical = new MovementInput(TurnRight: true);
_ = controller.Update(1f / 60f, physical);
controller.SetLastMoveWasAutonomous(false);
controller.Motion.MoveToInterpretedState(new InboundInterpretedState
{
CurrentStyle = 0x80000049u,
ForwardCommand = MotionCommand.Ready,
ForwardSpeed = 1f,
TurnCommand = 0u,
TurnSpeed = 1f,
});
MovementResult held = controller.Update(1f / 60f, physical);
Assert.False(held.ShouldSendMovementEvent);
Assert.Equal(0u, controller.Motion.InterpretedState.TurnCommand);
}
private static PlayerMovementController CreateController()
{
var engine = new PhysicsEngine();

View file

@ -7,6 +7,33 @@ namespace AcDream.Runtime.Tests.Gameplay;
public sealed class RuntimeActionStateTests
{
[Fact]
public void HealthActivityPublishesMonotonicRevisionAndAgeAndResets()
{
double now = 10d;
using var inventory = NewInventoryTransactions();
using var actions = RuntimeActionTestFactory.Create(inventory, () => now);
actions.Combat.OnUpdateHealth(0x50000001u, 0.75f);
now = 12.5d;
Assert.True(actions.TryGetHealthActivity(
0x50000001u, out long firstRevision, out double age));
Assert.Equal(1, firstRevision);
Assert.Equal(2.5d, age, 3);
actions.Combat.OnUpdateHealth(0x50000001u, 0.5f);
Assert.True(actions.TryGetHealthActivity(
0x50000001u, out long secondRevision, out age));
Assert.True(secondRevision > firstRevision);
Assert.Equal(0d, age, 3);
actions.ResetSession();
Assert.False(actions.TryGetHealthActivity(
0x50000001u, out _, out double missingAge));
Assert.True(double.IsPositiveInfinity(missingAge));
}
[Fact]
public void ViewProjectsTheExactCanonicalChildrenAndRevisions()
{

View file

@ -183,6 +183,27 @@ public sealed class RuntimeCombatAttackStateTests
Assert.Equal(0, cancels);
}
[Fact]
public void AttackDonePublishesOneCompletionReceiptAndResetClearsIt()
{
var combat = new CombatState();
using var controller = new RuntimeCombatAttackState(
combat,
canStartAttack: () => true,
sendAttack: (_, _) => true);
combat.OnAttackDone(47u, 0x1234u);
Assert.Equal(1, controller.CompletionRevision);
Assert.Equal(47u, controller.CompletionSequence);
Assert.Equal(0x1234u, controller.CompletionWeenieError);
controller.ResetSession();
Assert.Equal(0, controller.CompletionRevision);
Assert.Equal(0u, controller.CompletionSequence);
Assert.Equal(0u, controller.CompletionWeenieError);
}
[Fact]
public void StartAttackRequest_PreparesPlayerMovementBeforePowerBuild()
{

View file

@ -96,6 +96,24 @@ public sealed class RuntimeCombatModeStateTests
Assert.Equal(["intent", "equipment", "send:Melee"], operations.Trace);
}
[Fact]
public void ExplicitRequestSendsChosenPluginModeWithoutEquipmentGuessing()
{
var combat = new CombatState();
var operations = new Operations();
combat.CombatModeChanged += mode =>
operations.Trace.Add($"state:{mode}");
var state = new RuntimeCombatModeState(combat, operations);
RuntimeCombatModeRequestResult result = state.Request(CombatMode.Magic);
Assert.Equal(RuntimeCombatModeRequestStatus.Sent, result.Status);
Assert.Equal(CombatMode.Magic, result.Mode);
Assert.Equal(
["intent", "send:Magic", "state:Magic"],
operations.Trace);
}
private sealed class Operations : IRuntimeCombatModeOperations
{
public bool IsInWorld { get; init; } = true;

View file

@ -3,6 +3,7 @@ using AcDream.Core.Items;
using AcDream.Core.Net;
using AcDream.Core.Net.Messages;
using AcDream.Core.Physics;
using AcDream.Core.Properties;
using AcDream.Core.Spells;
using AcDream.Runtime.Entities;
using AcDream.Runtime.Gameplay;
@ -172,6 +173,43 @@ public sealed class RuntimeHostileTargetQueryTests
Assert.Null(RuntimeHostileTargetQuery.FindClosest(runtime));
}
[Fact]
public void Capture_ProjectsRetailRelativeHeadingRangeNameAndHealth()
{
using GameRuntime runtime = Create();
runtime.PlayerIdentity.ServerGuid = Player;
Add(runtime, Player, 0x01010001u, 10f, 10f, PlayerObject(Player));
ClientObject east = Hostile(0x50000020u);
east.Name = "East Drudge";
east.WeenieClassId = 1234u;
east.Properties.Ints[(uint)PropertyInt.CreatureType] = 4;
Add(runtime, east.ObjectId, 0x01010001u, 13f, 10f, east);
runtime.InventoryOwner.Objects.AddOrUpdate(new ClientObject
{
ObjectId = 0x70000020u,
Type = ItemType.Armor,
WielderId = east.ObjectId,
CurrentlyEquippedLocation = EquipMask.Shield,
});
runtime.ActionOwner.Combat.OnUpdateHealth(east.ObjectId, 0.75f);
IReadOnlyList<RuntimeHostileTargetSnapshot> captured =
RuntimeHostileTargetQuery.Capture(runtime, 4f);
RuntimeHostileTargetSnapshot target = Assert.Single(captured);
Assert.Equal(east.ObjectId, target.ObjectId);
Assert.Equal("East Drudge", target.Name);
Assert.Equal(1234u, target.WeenieClassId);
Assert.Equal(3f, target.Distance, 3);
Assert.Equal(90f, target.RelativeAngleDegrees, 3);
Assert.True(target.IsHealthKnown);
Assert.Equal(0.75f, target.HealthFraction, 3);
Assert.Equal(4, target.SpeciesId);
Assert.True(target.HasShield);
Assert.Equal(0, target.MaximumHealth);
Assert.Empty(RuntimeHostileTargetQuery.Capture(runtime, 2.9f));
}
private static GameRuntime Create()
{
var operations = new Operations();

View file

@ -54,6 +54,70 @@ public sealed class RuntimeInteractionTransactionStateTests
state.CompleteUse(0u);
Assert.Equal(0, inventory.BusyCount);
Assert.Equal(new RuntimeItemUseCompletion(1, Item, 0u, 0u),
state.LastItemUseCompletion);
Assert.False(state.CaptureOwnership().AwaitingItemUseCompletion);
}
[Fact]
public void TargetedItemUsePublishesExactAuthoritativeFailureReceipt()
{
using var inventory = NewInventory(out _);
using var state = new RuntimeInteractionTransactionState(inventory);
Assert.True(state.TryDispatchTargetedUse(
Item,
Container,
static (_, _) => { },
incrementBusy: true));
Assert.True(state.CaptureOwnership().AwaitingItemUseCompletion);
state.CompleteUse(0x0402u);
Assert.Equal(
new RuntimeItemUseCompletion(1, Item, Container, 0x0402u),
state.LastItemUseCompletion);
Assert.False(state.LastItemUseCompletion.IsSuccess);
Assert.False(state.CaptureOwnership().AwaitingItemUseCompletion);
}
[Fact]
public void UseDoneWithoutPluginItemRequestCannotFabricateItemReceipt()
{
using var inventory = NewInventory(out _);
using var state = new RuntimeInteractionTransactionState(inventory);
state.IncrementBusyCount();
state.CompleteUse(0u);
Assert.Equal(default, state.LastItemUseCompletion);
Assert.Equal(0, inventory.BusyCount);
}
[Fact]
public void ItemUseReceiptIsGenerationScoped()
{
using var inventory = NewInventory(out _);
using var state = new RuntimeInteractionTransactionState(inventory);
Assert.True(state.TryDispatchTargetedUse(
Item,
Container,
static (_, _) => { },
incrementBusy: true));
state.CompleteUse(0u);
Assert.True(state.LastItemUseCompletion.IsSuccess);
state.ResetSession();
Assert.Equal(default, state.LastItemUseCompletion);
Assert.True(state.TryDispatchTargetedUse(
Container,
Item,
static (_, _) => { },
incrementBusy: true));
state.CompleteUse(0u);
Assert.Equal(1, state.LastItemUseCompletion.Revision);
Assert.Equal(Container, state.LastItemUseCompletion.SourceObjectId);
}
[Theory]

View file

@ -54,6 +54,40 @@ public sealed class RuntimeLocalPlayerMovementStateTests
Assert.Equal(movement.Revision, snapshot.Revision);
}
[Fact]
public void ViewProjectsLiveBodyOrientationAfterLocalTurn()
{
var controller = new PlayerMovementController(new PhysicsEngine())
{
LocalEntityId = 0x50000001u,
};
controller.SeedPlacementForTest(
new Vector3(11f, 12f, 13f),
0xA9B40001u,
new Vector3(11f, 12f, 13f));
using var movement = new RuntimeLocalPlayerMovementState
{
Controller = controller,
};
Quaternion turned = AcDream.Core.Physics.Motion.MoveToMath.SetHeading(
Quaternion.Identity,
90f);
controller.SetBodyOrientation(turned);
// PhysicsBody.CellPosition deliberately retains the carried frame
// rotation. RuntimeMovementSnapshot is the live consumer projection
// used by plugins and must expose the body's current facing instead.
Assert.NotEqual(turned, controller.CellPosition.Frame.Orientation);
RuntimeMovementSnapshot snapshot = movement.View.Snapshot;
Assert.Equal(turned, snapshot.Position.Frame.Orientation);
Assert.Equal(
90f,
AcDream.Core.Physics.Motion.MoveToMath.GetHeading(
snapshot.Position.Frame.Orientation),
precision: 4);
}
[Fact]
public void GraphicalAndDirectCommandsMutateOneAutorunLatch()
{

View file

@ -103,6 +103,40 @@ public sealed class RuntimeSpellCastStateTests
Assert.Null(state.LastRequestedSpellId);
}
[Fact]
public void CompleteUse_PublishesOneExactServerReceipt()
{
Spellbook book = MakeBook(flags: 0, untargeted: false, targetMask: 0x10);
var operations = new FakeOperations { LocalPlayerId = 42u };
RuntimeSpellCastState state = Create(book, operations, selected: 99u);
Assert.Equal(CastRequestResult.Sent, state.Cast(1));
Assert.Equal(1u, state.PendingSpellId);
Assert.Equal(99u, state.PendingTargetId);
Assert.True(state.CompleteUse(0x0402u));
Assert.Equal(
new RuntimeSpellCastCompletion(1, 1u, 99u, 0x0402u),
state.LastCompletion);
Assert.Null(state.PendingSpellId);
Assert.False(state.CompleteUse(0u));
Assert.Equal(1, state.LastCompletion.Revision);
}
[Fact]
public void Cast_DoesNotOverwritePendingReceiptIdentity()
{
Spellbook book = MakeBook(flags: 0, untargeted: false, targetMask: 0x10);
var operations = new FakeOperations { LocalPlayerId = 42u };
RuntimeSpellCastState state = Create(book, operations, selected: 99u);
Assert.Equal(CastRequestResult.Sent, state.Cast(1));
Assert.Equal(CastRequestResult.Unavailable, state.Cast(1));
Assert.Equal(1, operations.TargetedSends);
Assert.Equal(1u, state.PendingSpellId);
Assert.Equal(99u, state.PendingTargetId);
}
private static RuntimeSpellCastState Create(
Spellbook book,
FakeOperations operations,

View file

@ -1750,6 +1750,45 @@ public sealed class LiveSessionControllerTests
Assert.True(host.CommandBuses[^1].Active);
}
[Fact]
public void NextLoginAutomaticallyEntersTheRememberedRosterCharacterOnce()
{
var calls = new List<string>();
var operations = new TestOperations(calls)
{
Characters = new CharacterList.Parsed(
0u,
[
new CharacterList.Character(0x50000010u, "Alpha", 0u),
new CharacterList.Character(0x50000020u, "Beta", 0u),
],
[],
11,
"Canonical",
true,
true),
};
var host = new TestHost(calls);
var controller = new LiveSessionController(operations);
Assert.Equal(
LiveSessionStartStatus.Connected,
controller.Start(LiveOptions(), host).Status);
Assert.Equal(0x50000010u, host.Selections[^1].CharacterId);
Assert.True(controller.TrySetNextLogin(0x50000020u));
Assert.Equal(0x50000020u, controller.NextLoginCharacterId);
RuntimeCommandResult result =
controller.CompleteCharacterLogOff(controller.Generation);
Assert.True(result.Accepted);
Assert.True(controller.IsInWorld);
Assert.Equal(0u, controller.NextLoginCharacterId);
Assert.Equal(2, operations.EnterWorldCount);
Assert.Equal(0x50000020u, host.Selections[^1].CharacterId);
Assert.Contains("enter:1", calls);
Assert.True(controller.ClearNextLogin());
}
[Fact]
public void CompleteCharacterLogOff_RefusalsAndFailureDegradeToStop()
{

View file

@ -7,6 +7,23 @@ namespace AcDream.UI.Abstractions.Tests.Panels.Chat;
public class ChatCommandRouterTests
{
[Fact]
public void PluginCommand_IsHandledBeforeUnknownServerFallback()
{
var (vm, _, inner) = Fixture();
var bus = new PluginCommandBus(inner);
SubmitOutcome outcome = ChatCommandRouter.Submit(
"/vt start",
vm,
bus,
ChatChannelKind.Say);
Assert.Equal(SubmitOutcome.ClientHandled, outcome);
Assert.Equal("/vt start", bus.Handled);
Assert.Empty(inner.Published);
}
private sealed class CaptureBus : ICommandBus
{
public List<object> Published { get; } = new();
@ -15,6 +32,21 @@ public class ChatCommandRouterTests
=> Published.Add(command);
}
private sealed class PluginCommandBus(CaptureBus inner)
: IPluginCommandBus
{
public string? Handled { get; private set; }
public bool TryHandlePluginCommand(string commandLine)
{
Handled = commandLine;
return true;
}
public void Publish<T>(T command) where T : notnull =>
inner.Publish(command);
}
private static (ChatVM vm, ChatLog log, CaptureBus bus) Fixture()
{
var log = new ChatLog();