feat(mosstank): add VTank-style automation PoC
This commit is contained in:
parent
f6fe0f2a4f
commit
4e6e9bc9d9
212 changed files with 49462 additions and 416 deletions
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
277
tests/AcDream.App.Tests/Plugins/AppAutomationSurfaceTests.cs
Normal file
277
tests/AcDream.App.Tests/Plugins/AppAutomationSurfaceTests.cs
Normal 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() { }
|
||||
}
|
||||
}
|
||||
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
35
tests/AcDream.App.Tests/Plugins/FilePluginStorageTests.cs
Normal file
35
tests/AcDream.App.Tests/Plugins/FilePluginStorageTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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!());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
132
tests/AcDream.App.Tests/UI/PluginSidePanelTests.cs
Normal file
132
tests/AcDream.App.Tests/UI/PluginSidePanelTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -58,4 +58,25 @@ public sealed class ClientObjectTableUpdateTests
|
|||
[Fact]
|
||||
public void UpdateStackSize_unknownObject_returnsFalse()
|
||||
=> Assert.False(new ClientObjectTable().UpdateStackSize(0xDEADu, 1, 1));
|
||||
|
||||
[Fact]
|
||||
public void UpdateAppraisal_retainsPropertiesAndDefensiveSpellSnapshot()
|
||||
{
|
||||
var table = new ClientObjectTable();
|
||||
table.AddOrUpdate(new ClientObject { ObjectId = 0x700u });
|
||||
int updates = 0;
|
||||
table.ObjectUpdated += _ => updates++;
|
||||
var bundle = new PropertyBundle();
|
||||
bundle.Ints[106] = 420;
|
||||
uint[] spells = [1327u, 1132u];
|
||||
|
||||
Assert.True(table.UpdateAppraisal(0x700u, bundle, spells, 12.345d));
|
||||
spells[0] = 0u;
|
||||
|
||||
ClientObject item = table.Get(0x700u)!;
|
||||
Assert.Equal(420, item.Properties.Ints[106]);
|
||||
Assert.Equal([1327u, 1132u], item.AppraisedSpellIds);
|
||||
Assert.Equal(12345, item.LastAppraisalTimeMs);
|
||||
Assert.Equal(1, updates);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,59 @@
|
|||
using AcDream.Core.Plugins;
|
||||
using AcDream.Plugin.Abstractions;
|
||||
|
||||
namespace AcDream.Core.Tests.Plugins;
|
||||
|
||||
public sealed class PluginCommandRegistryTests
|
||||
{
|
||||
[Fact]
|
||||
public void RegisteredVerbHandlesBothPrefixesCaseInsensitively()
|
||||
{
|
||||
var registry = new PluginCommandRegistry();
|
||||
var seen = new List<PluginCommand>();
|
||||
using IDisposable lease = registry.Register("vt", seen.Add);
|
||||
|
||||
Assert.True(registry.TryHandle("/VT start"));
|
||||
Assert.True(registry.TryHandle(" @vt opt get EnableCombat "));
|
||||
Assert.Equal(2, seen.Count);
|
||||
Assert.Equal("start", seen[0].Arguments);
|
||||
Assert.Equal("opt get EnableCombat", seen[1].Arguments);
|
||||
Assert.Equal("/VT start", seen[0].RawText);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExactLeaseRemovalDoesNotConsumeUnknownServerCommand()
|
||||
{
|
||||
var registry = new PluginCommandRegistry();
|
||||
IDisposable lease = registry.Register("vt", static _ => { });
|
||||
lease.Dispose();
|
||||
|
||||
Assert.False(registry.TryHandle("/vt start"));
|
||||
Assert.False(registry.TryHandle("hello"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DuplicateVerbIsRejectedWithoutReplacingOwner()
|
||||
{
|
||||
var registry = new PluginCommandRegistry();
|
||||
int calls = 0;
|
||||
using IDisposable lease = registry.Register("vt", _ => calls++);
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() =>
|
||||
registry.Register("VT", static _ => { }));
|
||||
Assert.True(registry.TryHandle("/vt"));
|
||||
Assert.Equal(1, calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HandlerFailureIsContainedAndReported()
|
||||
{
|
||||
Exception? failure = null;
|
||||
var registry = new PluginCommandRegistry((_, error) => failure = error);
|
||||
using IDisposable lease = registry.Register(
|
||||
"vt",
|
||||
static _ => throw new InvalidOperationException("broken"));
|
||||
|
||||
Assert.True(registry.TryHandle("/vt start"));
|
||||
Assert.Equal("broken", failure?.Message);
|
||||
}
|
||||
}
|
||||
|
|
@ -9,6 +9,11 @@ public class PluginLoaderTests
|
|||
{
|
||||
private static string FixturePluginPath()
|
||||
{
|
||||
const string fileName = "AcDream.Core.Tests.Fixtures.HelloPlugin.dll";
|
||||
string colocated = Path.Combine(AppContext.BaseDirectory, fileName);
|
||||
if (File.Exists(colocated))
|
||||
return colocated;
|
||||
|
||||
// walk up from the test bin dir to the repo root, then into the fixture's build output
|
||||
var baseDir = AppContext.BaseDirectory;
|
||||
var configuration = new DirectoryInfo(baseDir).Parent!.Name; // Debug / Release
|
||||
|
|
@ -16,7 +21,7 @@ public class PluginLoaderTests
|
|||
return Path.Combine(
|
||||
repoRoot,
|
||||
"tests", "AcDream.Core.Tests.Fixtures.HelloPlugin", "bin", configuration, "net10.0",
|
||||
"AcDream.Core.Tests.Fixtures.HelloPlugin.dll");
|
||||
fileName);
|
||||
}
|
||||
|
||||
private static string FindRepoRoot(string startDir)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,80 @@
|
|||
using AcDream.Core.Plugins;
|
||||
using AcDream.Plugin.Abstractions;
|
||||
|
||||
namespace AcDream.Core.Tests.Plugins;
|
||||
|
||||
public sealed class PluginLootClassifierRegistryTests
|
||||
{
|
||||
[Fact]
|
||||
public void RegistrationClassifiesAndDisposalRemovesEntry()
|
||||
{
|
||||
var registry = new PluginLootClassifierRegistry();
|
||||
var classifier = new Classifier();
|
||||
IDisposable registration = registry.Register(
|
||||
"test/rules",
|
||||
"Test Rules",
|
||||
classifier);
|
||||
var context = new PluginLootClassificationContext(
|
||||
default,
|
||||
default,
|
||||
[]);
|
||||
|
||||
Assert.Equal("test/rules", Assert.Single(registry.Available).Id);
|
||||
Assert.True(registry.TryClassify(
|
||||
"TEST/RULES",
|
||||
context,
|
||||
out PluginLootClassification classification));
|
||||
Assert.True(classification.Matched);
|
||||
Assert.Equal(PluginLootAction.Keep, classification.Action);
|
||||
Assert.True(registry.TryNotifyLooted(
|
||||
"test/rules",
|
||||
new PluginLootedItem(default, PluginLootAction.User1)));
|
||||
Assert.True(registry.TryNotifyItemRemoved("test/rules", 42u));
|
||||
Assert.Equal(PluginLootAction.User1, Assert.Single(classifier.Looted).Action);
|
||||
Assert.Equal(new[] { 42u }, classifier.Removed);
|
||||
|
||||
registration.Dispose();
|
||||
|
||||
Assert.Empty(registry.Available);
|
||||
Assert.False(registry.TryClassify(
|
||||
"test/rules",
|
||||
context,
|
||||
out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClassifierFailureIsIsolatedAsUnavailableDecision()
|
||||
{
|
||||
var registry = new PluginLootClassifierRegistry();
|
||||
using IDisposable registration = registry.Register(
|
||||
"bad/rules",
|
||||
"Bad Rules",
|
||||
new ThrowingClassifier());
|
||||
|
||||
Assert.False(registry.TryClassify(
|
||||
"bad/rules",
|
||||
new PluginLootClassificationContext(default, default, []),
|
||||
out _));
|
||||
}
|
||||
|
||||
private sealed class Classifier : IPluginLootClassifier
|
||||
{
|
||||
public List<PluginLootedItem> Looted { get; } = [];
|
||||
public List<uint> Removed { get; } = [];
|
||||
|
||||
public PluginLootClassification Classify(
|
||||
in PluginLootClassificationContext context) =>
|
||||
new(true, PluginLootAction.Keep, "External");
|
||||
|
||||
public void OnLooted(in PluginLootedItem item) => Looted.Add(item);
|
||||
|
||||
public void OnItemRemoved(uint objectId) => Removed.Add(objectId);
|
||||
}
|
||||
|
||||
private sealed class ThrowingClassifier : IPluginLootClassifier
|
||||
{
|
||||
public PluginLootClassification Classify(
|
||||
in PluginLootClassificationContext context) =>
|
||||
throw new InvalidOperationException("classifier failed");
|
||||
}
|
||||
}
|
||||
|
|
@ -8,6 +8,56 @@ namespace AcDream.Core.Tests.Plugins;
|
|||
|
||||
public sealed class PluginSessionTests
|
||||
{
|
||||
[Fact]
|
||||
public void ScopedHostPrefixesStorageWithAuthenticatedManifestId()
|
||||
{
|
||||
var storage = new MemoryStorage();
|
||||
using var alpha = new ScopedPluginHost(
|
||||
new StubHost(storage),
|
||||
"acdream.alpha",
|
||||
"Alpha");
|
||||
using var beta = new ScopedPluginHost(
|
||||
new StubHost(storage),
|
||||
"acdream.beta",
|
||||
"Beta");
|
||||
|
||||
alpha.Storage.WriteText("profile.json", "alpha");
|
||||
beta.Storage.WriteText("profile.json", "beta");
|
||||
alpha.Storage.WriteText("imports/route.nav", "nav");
|
||||
|
||||
Assert.Equal("alpha", storage.Text[Path.Combine(
|
||||
"acdream.alpha", "profile.json")]);
|
||||
Assert.Equal("beta", storage.Text[Path.Combine(
|
||||
"acdream.beta", "profile.json")]);
|
||||
Assert.Equal(["imports/route.nav"], alpha.Storage.List("imports"));
|
||||
Assert.Empty(beta.Storage.List("imports"));
|
||||
Assert.Throws<ArgumentException>(() =>
|
||||
alpha.Storage.WriteText("../escape.json", "bad"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScopedHostNamespacesAndUnregistersLootClassifierOnDispose()
|
||||
{
|
||||
var global = new PluginLootClassifierRegistry();
|
||||
var scope = new ScopedPluginHost(
|
||||
new StubHost(lootClassifiers: global),
|
||||
"acdream.looter",
|
||||
"Looter");
|
||||
|
||||
scope.LootClassifiers.Register(
|
||||
"main",
|
||||
"My loot rules",
|
||||
new KeepClassifier());
|
||||
|
||||
PluginLootClassifierInfo registered = Assert.Single(global.Available);
|
||||
Assert.Equal("acdream.looter/main", registered.Id);
|
||||
Assert.Equal("My loot rules", registered.DisplayName);
|
||||
|
||||
scope.Dispose();
|
||||
|
||||
Assert.Empty(global.Available);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AbsentAllowListLoadsEveryDiscoveredPlugin()
|
||||
{
|
||||
|
|
@ -282,6 +332,11 @@ public sealed class PluginSessionTests
|
|||
|
||||
private static string FixturePluginPath()
|
||||
{
|
||||
string fileName = "AcDream.Core.Tests.Fixtures.HelloPlugin.dll";
|
||||
string colocated = Path.Combine(AppContext.BaseDirectory, fileName);
|
||||
if (File.Exists(colocated))
|
||||
return colocated;
|
||||
|
||||
string configuration = new DirectoryInfo(AppContext.BaseDirectory)
|
||||
.Parent!.Name;
|
||||
string root = FindRepoRoot(AppContext.BaseDirectory);
|
||||
|
|
@ -292,7 +347,7 @@ public sealed class PluginSessionTests
|
|||
"bin",
|
||||
configuration,
|
||||
"net10.0",
|
||||
"AcDream.Core.Tests.Fixtures.HelloPlugin.dll");
|
||||
fileName);
|
||||
}
|
||||
|
||||
private static string FindRepoRoot(string start)
|
||||
|
|
@ -307,7 +362,9 @@ public sealed class PluginSessionTests
|
|||
?? throw new InvalidOperationException("Repository root not found.");
|
||||
}
|
||||
|
||||
private sealed class StubHost : IPluginHost
|
||||
private sealed class StubHost(
|
||||
IPluginStorage? storage = null,
|
||||
IPluginLootClassifierRegistry? lootClassifiers = null) : IPluginHost
|
||||
{
|
||||
public bool HasUi => false;
|
||||
public IPluginLogger Log { get; } = new StubLogger();
|
||||
|
|
@ -316,6 +373,34 @@ public sealed class PluginSessionTests
|
|||
public ISelectionService Selection { get; } = new SelectionState();
|
||||
public IUiRegistry Ui => NoOpUiRegistry.Instance;
|
||||
public IAutomationSurface Automation => NoOpAutomationSurface.Instance;
|
||||
public IPluginStorage Storage { get; } =
|
||||
storage ?? NoOpPluginStorage.Instance;
|
||||
public IPluginLootClassifierRegistry LootClassifiers { get; } =
|
||||
lootClassifiers ?? NoOpPluginLootClassifierRegistry.Instance;
|
||||
}
|
||||
|
||||
private sealed class KeepClassifier : IPluginLootClassifier
|
||||
{
|
||||
public PluginLootClassification Classify(
|
||||
in PluginLootClassificationContext context) => new(
|
||||
true,
|
||||
PluginLootAction.Keep);
|
||||
}
|
||||
|
||||
private sealed class MemoryStorage : IPluginStorage
|
||||
{
|
||||
public Dictionary<string, string> Text { get; } =
|
||||
new(StringComparer.Ordinal);
|
||||
public bool IsAvailable => true;
|
||||
public string? ReadText(string key) =>
|
||||
Text.TryGetValue(key, out string? value) ? value : null;
|
||||
public IReadOnlyList<string> List(string prefix) => Text.Keys
|
||||
.Where(key => key.StartsWith(prefix + Path.DirectorySeparatorChar,
|
||||
StringComparison.Ordinal))
|
||||
.OrderBy(static key => key, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
public void WriteText(string key, string content) => Text[key] = content;
|
||||
public bool Delete(string key) => Text.Remove(key);
|
||||
}
|
||||
|
||||
private sealed class StubLogger : IPluginLogger
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
347
tests/AcDream.Plugins.MossTank.Tests/AttackSpellCatalogTests.cs
Normal file
347
tests/AcDream.Plugins.MossTank.Tests/AttackSpellCatalogTests.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
118
tests/AcDream.Plugins.MossTank.Tests/AutoAttackPowerTests.cs
Normal file
118
tests/AcDream.Plugins.MossTank.Tests/AutoAttackPowerTests.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
1228
tests/AcDream.Plugins.MossTank.Tests/CombatControllerTests.cs
Normal file
1228
tests/AcDream.Plugins.MossTank.Tests/CombatControllerTests.cs
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
393
tests/AcDream.Plugins.MossTank.Tests/CraftingTests.cs
Normal file
393
tests/AcDream.Plugins.MossTank.Tests/CraftingTests.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
219
tests/AcDream.Plugins.MossTank.Tests/DebuffSchedulerTests.cs
Normal file
219
tests/AcDream.Plugins.MossTank.Tests/DebuffSchedulerTests.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
375
tests/AcDream.Plugins.MossTank.Tests/DispelControllerTests.cs
Normal file
375
tests/AcDream.Plugins.MossTank.Tests/DispelControllerTests.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
194
tests/AcDream.Plugins.MossTank.Tests/ExpressionEngineTests.cs
Normal file
194
tests/AcDream.Plugins.MossTank.Tests/ExpressionEngineTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
201
tests/AcDream.Plugins.MossTank.Tests/FellowshipManagerTests.cs
Normal file
201
tests/AcDream.Plugins.MossTank.Tests/FellowshipManagerTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
25
tests/AcDream.Plugins.MossTank.Tests/GrenadeCatalogTests.cs
Normal file
25
tests/AcDream.Plugins.MossTank.Tests/GrenadeCatalogTests.cs
Normal 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 _));
|
||||
}
|
||||
}
|
||||
|
|
@ -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 { }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
1000
tests/AcDream.Plugins.MossTank.Tests/LootingTests.cs
Normal file
1000
tests/AcDream.Plugins.MossTank.Tests/LootingTests.cs
Normal file
File diff suppressed because it is too large
Load diff
286
tests/AcDream.Plugins.MossTank.Tests/MetaEngineTests.cs
Normal file
286
tests/AcDream.Plugins.MossTank.Tests/MetaEngineTests.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
202
tests/AcDream.Plugins.MossTank.Tests/MetaViewManagerTests.cs
Normal file
202
tests/AcDream.Plugins.MossTank.Tests/MetaViewManagerTests.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
164
tests/AcDream.Plugins.MossTank.Tests/MonsterExpressionTests.cs
Normal file
164
tests/AcDream.Plugins.MossTank.Tests/MonsterExpressionTests.cs
Normal 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);
|
||||
}
|
||||
|
|
@ -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
623
tests/AcDream.Plugins.MossTank.Tests/NavigationTests.cs
Normal file
623
tests/AcDream.Plugins.MossTank.Tests/NavigationTests.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
283
tests/AcDream.Plugins.MossTank.Tests/PetAutomationTests.cs
Normal file
283
tests/AcDream.Plugins.MossTank.Tests/PetAutomationTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
397
tests/AcDream.Plugins.MossTank.Tests/VitalRechargeTests.cs
Normal file
397
tests/AcDream.Plugins.MossTank.Tests/VitalRechargeTests.cs
Normal 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) { }
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 ?? [],
|
||||
};
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue