Item C (slice-1 fix round). MossTankMetaProfileStore and
MossTankRouteProfileStore both wrote their .af exports into the same
flat "exports/" directory keyed only by profile name — a Meta profile
and a route (Navigation) profile sharing a name (e.g. both named
"Same") would silently clobber each other's .af file on save, with no
error and no warning.
- MossTankMetaProfileStore.WriteLegacyExport now writes to
"exports/meta/{name}.af".
- MossTankRouteProfileStore.WriteLegacyExport now writes to
"exports/nav/{name}.af".
- Both class docs updated to name the collision this avoids and point at
the sibling store's subdirectory.
- MossTankPanelTests: existing NavCommandsImportAndExportExactVtankNavFiles
and MetaCommandsImportAndExportExactVtankMetFiles updated to the new
paths. New MetaAndRouteExportsWithTheSameNameDoNotCollide saves a Meta
and a route profile both named "Same" and asserts both .af files exist
with their own correct content — verified failing before the fix
(asserted false on the meta file's existence once both paths were
reverted to the flat "exports/" root, confirming the collision is real
and this test catches it).
Full MossTank suite: 569/569 passing (568 -> 569, one new test).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1817 lines
66 KiB
C#
1817 lines
66 KiB
C#
using AcDream.Plugin.Abstractions;
|
|
|
|
namespace AcDream.Plugins.MossTank.Tests;
|
|
|
|
public sealed class MossTankPanelTests
|
|
{
|
|
[Fact]
|
|
public void CorruptProfileIsPreservedAndReportedBeforeDefaultsLoad()
|
|
{
|
|
var storage = new MemoryStorage();
|
|
storage.Text["profiles/index.json"] = "{ this is not json";
|
|
|
|
var panel = new MossTankPanel(
|
|
new FakeHost(new FakeAutomation(), storage));
|
|
|
|
Assert.Contains(
|
|
"Raw data was preserved",
|
|
panel.ProfileLifecycleNotice,
|
|
StringComparison.Ordinal);
|
|
KeyValuePair<string, string> backup = Assert.Single(
|
|
storage.Text,
|
|
static pair => pair.Key.StartsWith(
|
|
"recovery/macro/",
|
|
StringComparison.Ordinal));
|
|
Assert.Contains("profiles/index.json", backup.Value, StringComparison.Ordinal);
|
|
Assert.Contains("{ this is not json", backup.Value, StringComparison.Ordinal);
|
|
}
|
|
|
|
[Fact]
|
|
public void FirstRunGuidanceExplainsProfilesImportsAndPersistentShelfOnce()
|
|
{
|
|
var storage = new MemoryStorage();
|
|
var automation = new FakeAutomation();
|
|
var panel = new MossTankPanel(new FakeHost(automation, storage));
|
|
|
|
panel.OnTick(0d);
|
|
panel.OnTick(0d);
|
|
|
|
string message = Assert.Single(
|
|
automation.Messages,
|
|
static value => value.Contains("First run:", StringComparison.Ordinal));
|
|
Assert.Contains("plugin shelf", message, StringComparison.Ordinal);
|
|
Assert.Contains(".nav/.utl/.met", message, StringComparison.Ordinal);
|
|
Assert.Equal("shown", storage.Text["onboarding/v1.txt"]);
|
|
}
|
|
|
|
[Fact]
|
|
public void RunningMacroRebuffsNormallyAndUsesWiderIdleTopoffOnlyWhenEnabled()
|
|
{
|
|
var automation = new FakeAutomation
|
|
{
|
|
CurrentHealth = 100,
|
|
MaxHealth = 100,
|
|
CurrentStamina = 100,
|
|
MaxStamina = 100,
|
|
CurrentMana = 100,
|
|
MaxMana = 100,
|
|
Skills =
|
|
[
|
|
new PluginSkillInfo(
|
|
1,
|
|
"Life Magic",
|
|
PluginSkillTraining.Trained,
|
|
300),
|
|
],
|
|
KnownSelfBuffs =
|
|
[
|
|
Spell(
|
|
1,
|
|
10,
|
|
"Increases the caster's Life Magic skill by 10 points."),
|
|
],
|
|
ActiveEnchantments = [new PluginActiveEnchantment(1, 10, 1, 600)],
|
|
};
|
|
var panel = new MossTankPanel(new FakeHost(automation));
|
|
|
|
panel.ToggleCombat();
|
|
panel.OnTick(0d);
|
|
Assert.Empty(automation.CastSpellIds);
|
|
|
|
panel.ToggleIdleBuffTopoff();
|
|
panel.OnTick(1d);
|
|
|
|
Assert.Equal([1u], automation.CastSpellIds);
|
|
Assert.StartsWith("Buffing", panel.BuffStatus, StringComparison.Ordinal);
|
|
}
|
|
|
|
[Fact]
|
|
public void MacroWieldsCasterEntersMagicBuffsThenWieldsWeaponFightsThenIdlePeace()
|
|
{
|
|
// Test 6 of docs/plans/2026-09-06-mosstank-mode-arbitration.md: the
|
|
// owner scenario. A profiled wand and melee weapon, buffing and
|
|
// combat both enabled, one buff due and one hostile in range, macro
|
|
// started in Peace.
|
|
PluginSpellInfo buff = Spell(
|
|
1, 10, "Increases the caster's Life Magic skill by 10 points.");
|
|
var automation = new CombatCapableFakeAutomation
|
|
{
|
|
CurrentHealth = 100,
|
|
MaxHealth = 100,
|
|
CurrentStamina = 100,
|
|
MaxStamina = 100,
|
|
CurrentMana = 100,
|
|
MaxMana = 100,
|
|
Skills = [new PluginSkillInfo(1, "Life Magic", PluginSkillTraining.Trained, 300)],
|
|
KnownSelfBuffs = [buff],
|
|
ItemEntries =
|
|
[
|
|
Item(10, "War Wand", itemType: 0x00008000u),
|
|
Item(20, "Battle Axe", itemType: 1),
|
|
],
|
|
EquipmentItems =
|
|
[
|
|
EquipmentItem(10, "War Wand", itemType: 0x00008000u),
|
|
EquipmentItem(20, "Battle Axe", itemType: 1),
|
|
],
|
|
Targets = [new PluginCombatTarget(30, "Drudge", 700, 2f, 0f, true, 1f)],
|
|
};
|
|
var host = new FakeHost(automation);
|
|
var panel = new MossTankPanel(host);
|
|
|
|
host.Selection.Select(10);
|
|
panel.AddSelectedItem(); // Items profile: the wand
|
|
host.Selection.Select(20);
|
|
panel.SetMonsterWeapon(); // DEFAULT rule's weapon: the axe
|
|
// (DEFAULT's Attack flag is already on by construction — VTank's own
|
|
// MonsterRuleActions default — so it is never toggled here.)
|
|
panel.ToggleIdlePeaceMode();
|
|
|
|
panel.ToggleCombat(); // Run Macro, starting in Peace
|
|
|
|
bool attacked = false;
|
|
for (int tick = 0; tick < 60 && !attacked; tick++)
|
|
{
|
|
panel.OnTick(0.1);
|
|
attacked = automation.BeginCount > 0;
|
|
}
|
|
Assert.True(
|
|
attacked,
|
|
"Never attacked. CallLog: " + string.Join(" | ", automation.CallLog));
|
|
|
|
int equipWand = automation.CallLog.IndexOf("Equip:0000000A");
|
|
int enterMagic = automation.CallLog.IndexOf("EnterMode:Magic");
|
|
int cast = automation.CallLog.IndexOf("Cast:1");
|
|
int enterPeaceForWeapon = cast < 0
|
|
? -1
|
|
: automation.CallLog.FindIndex(cast + 1, entry => entry == "EnterMode:Peace");
|
|
int equipWeapon = automation.CallLog.IndexOf("Equip:00000014");
|
|
int defaultMode = automation.CallLog.IndexOf("EnterDefaultMode:Melee");
|
|
int attack = automation.CallLog.IndexOf("Attack:0000001E");
|
|
|
|
// Peace(already) -> Equip wand: no separate peace request was needed
|
|
// for the wand because the macro started in Peace already.
|
|
Assert.True(equipWand >= 0, "wand was never equipped");
|
|
Assert.DoesNotContain(
|
|
"EnterMode:Peace",
|
|
automation.CallLog.Take(equipWand));
|
|
// -> Magic -> cast
|
|
Assert.True(enterMagic > equipWand, "Magic requested before the wand was wielded");
|
|
Assert.True(cast > enterMagic, "cast happened before Magic mode was entered");
|
|
// (pass ends) -> Peace -> Equip weapon -> default mode -> attack
|
|
Assert.True(
|
|
enterPeaceForWeapon > cast,
|
|
"no peace request to wield the weapon after the buff pass");
|
|
Assert.True(
|
|
equipWeapon > enterPeaceForWeapon,
|
|
"weapon equipped before peace mode for it was entered");
|
|
Assert.True(defaultMode > equipWeapon, "default mode entered before the weapon was equipped");
|
|
Assert.True(attack > defaultMode, "attack began before the default mode was entered");
|
|
|
|
// With the hostile gone and Peace Mode When Idle on, the macro
|
|
// returns to peace by itself.
|
|
automation.Targets = [];
|
|
for (int tick = 0;
|
|
tick < 60 && automation.CombatSnapshot.Mode != PluginCombatMode.Peace;
|
|
tick++)
|
|
{
|
|
panel.OnTick(0.5);
|
|
}
|
|
Assert.Equal(PluginCombatMode.Peace, automation.CombatSnapshot.Mode);
|
|
}
|
|
|
|
[Fact]
|
|
public void FastCastBuffsHoldsForwardOnlyUntilInstantBuffCastEnds()
|
|
{
|
|
var automation = new FakeAutomation
|
|
{
|
|
Skills =
|
|
[
|
|
new PluginSkillInfo(
|
|
1,
|
|
"Life Magic",
|
|
PluginSkillTraining.Trained,
|
|
300),
|
|
],
|
|
KnownSelfBuffs =
|
|
[
|
|
Spell(
|
|
1,
|
|
10,
|
|
"Increases the caster's Life Magic skill by 10 points."),
|
|
],
|
|
};
|
|
var panel = new MossTankPanel(new FakeHost(automation));
|
|
Command(panel, "opt set FastCastBuffs true");
|
|
|
|
panel.ForceBuff();
|
|
panel.OnTick(0d);
|
|
|
|
PluginMovementIntent held = Assert.Single(automation.MovementIntents);
|
|
Assert.True(held.Forward);
|
|
Assert.Equal(0, automation.ClearMovementCount);
|
|
|
|
panel.OnTick(0.25d);
|
|
|
|
Assert.Equal(1, automation.ClearMovementCount);
|
|
}
|
|
|
|
[Fact]
|
|
public void FastCastBuffsNeverAppliesForwardMovementToWarSpells()
|
|
{
|
|
var automation = new FakeAutomation
|
|
{
|
|
Skills =
|
|
[
|
|
new PluginSkillInfo(
|
|
1,
|
|
"Life Magic",
|
|
PluginSkillTraining.Trained,
|
|
300),
|
|
],
|
|
KnownSelfBuffs =
|
|
[
|
|
Spell(
|
|
1,
|
|
10,
|
|
"Increases the caster's Life Magic skill by 10 points.")
|
|
with { School = 34u },
|
|
],
|
|
};
|
|
var panel = new MossTankPanel(new FakeHost(automation));
|
|
Command(panel, "opt set FastCastBuffs true");
|
|
|
|
panel.ForceBuff();
|
|
panel.OnTick(0d);
|
|
|
|
Assert.Empty(automation.MovementIntents);
|
|
}
|
|
|
|
[Fact]
|
|
public void RetainedLabelReads_UseUpdateSideSnapshotsWithoutAllocating()
|
|
{
|
|
var automation = new FakeAutomation
|
|
{
|
|
CurrentHealth = 90,
|
|
MaxHealth = 100,
|
|
CurrentStamina = 80,
|
|
MaxStamina = 110,
|
|
CurrentMana = 70,
|
|
MaxMana = 120,
|
|
Skills =
|
|
[
|
|
new PluginSkillInfo(1, "Life Magic", PluginSkillTraining.Trained, 300),
|
|
new PluginSkillInfo(2, "War Magic", PluginSkillTraining.Specialized, 350),
|
|
new PluginSkillInfo(3, "Run", PluginSkillTraining.Untrained, 100),
|
|
],
|
|
Attributes =
|
|
[
|
|
new PluginAttributeInfo(0, "Strength", 100),
|
|
new PluginAttributeInfo(1, "Endurance", 100),
|
|
],
|
|
KnownSelfBuffs =
|
|
[
|
|
Spell(1, 10, "Increases the caster's Life Magic skill by 10 points."),
|
|
],
|
|
};
|
|
var panel = new MossTankPanel(new FakeHost(automation));
|
|
|
|
panel.OnTick(0.0);
|
|
|
|
Assert.Equal("Health 90/100 Stam 80/110 Mana 70/120", panel.Vitals);
|
|
Assert.Equal("2 attributes, 2 trained skills, 1 buff lines", panel.Coverage);
|
|
string expectedVitals = panel.Vitals;
|
|
string expectedCoverage = panel.Coverage;
|
|
|
|
_ = panel.Vitals;
|
|
_ = panel.Coverage;
|
|
long before = GC.GetAllocatedBytesForCurrentThread();
|
|
bool sameReferences = true;
|
|
for (int i = 0; i < 10_000; i++)
|
|
{
|
|
sameReferences &= ReferenceEquals(expectedVitals, panel.Vitals);
|
|
sameReferences &= ReferenceEquals(expectedCoverage, panel.Coverage);
|
|
}
|
|
long allocated = GC.GetAllocatedBytesForCurrentThread() - before;
|
|
|
|
Assert.True(sameReferences);
|
|
Assert.Equal(0, allocated);
|
|
Assert.Equal(1, automation.KnownSelfBuffReads);
|
|
}
|
|
|
|
[Fact]
|
|
public void UpdateTick_RefreshesRareCoverageAndChangedVitals()
|
|
{
|
|
var automation = new FakeAutomation
|
|
{
|
|
CurrentHealth = 90,
|
|
MaxHealth = 100,
|
|
CurrentStamina = 80,
|
|
MaxStamina = 110,
|
|
CurrentMana = 70,
|
|
MaxMana = 120,
|
|
Skills =
|
|
[
|
|
new PluginSkillInfo(1, "Life Magic", PluginSkillTraining.Trained, 300),
|
|
],
|
|
Attributes = [new PluginAttributeInfo(0, "Strength", 100)],
|
|
KnownSelfBuffs =
|
|
[
|
|
Spell(1, 10, "Increases the caster's Life Magic skill by 10 points."),
|
|
],
|
|
};
|
|
var panel = new MossTankPanel(new FakeHost(automation));
|
|
panel.OnTick(0.0);
|
|
|
|
automation.CurrentHealth = 75;
|
|
automation.Skills =
|
|
[
|
|
new PluginSkillInfo(1, "Life Magic", PluginSkillTraining.Trained, 300),
|
|
new PluginSkillInfo(2, "War Magic", PluginSkillTraining.Specialized, 350),
|
|
];
|
|
panel.OnTick(0.5);
|
|
|
|
Assert.StartsWith("Health 75/100", panel.Vitals, StringComparison.Ordinal);
|
|
Assert.Equal("1 attributes, 1 trained skills, 1 buff lines", panel.Coverage);
|
|
|
|
panel.OnTick(0.5);
|
|
Assert.Equal("1 attributes, 2 trained skills, 1 buff lines", panel.Coverage);
|
|
|
|
automation.KnownSelfBuffs =
|
|
[
|
|
Spell(1, 10, "Increases the caster's Life Magic skill by 10 points."),
|
|
Spell(2, 11, "Increases the caster's War Magic skill by 10 points."),
|
|
];
|
|
panel.OnTick(0.0);
|
|
|
|
Assert.Equal("1 attributes, 2 trained skills, 2 buff lines", panel.Coverage);
|
|
}
|
|
|
|
[Fact]
|
|
public void ItemsAndConsumablesTabsAddTheSelectedOwnedItemToRealProfiles()
|
|
{
|
|
var automation = new FakeAutomation
|
|
{
|
|
ItemEntries =
|
|
[
|
|
Item(10, "Imperil Lens", 0x8000),
|
|
Item(11, "Iron Phial of Imperil", 0x100),
|
|
Item(12, "Black Marrow Pea", 0x20),
|
|
],
|
|
};
|
|
var host = new FakeHost(automation);
|
|
var panel = new MossTankPanel(host);
|
|
|
|
host.Selection.Select(10);
|
|
panel.AddSelectedItem();
|
|
Assert.Contains("Imperil Lens", panel.ItemProfileText, StringComparison.Ordinal);
|
|
|
|
host.Selection.Select(11);
|
|
panel.AddSelectedConsumable();
|
|
panel.AddAllPeas();
|
|
Assert.Contains(
|
|
"Iron Phial of Imperil",
|
|
panel.ConsumableProfileText,
|
|
StringComparison.Ordinal);
|
|
Assert.Contains(
|
|
CraftingPlanner.AllPeas,
|
|
panel.ConsumableProfileText,
|
|
StringComparison.Ordinal);
|
|
|
|
Assert.Contains("Imperil Lens", panel.ItemRows);
|
|
panel.RemoveSelectedItem();
|
|
Assert.DoesNotContain("Imperil Lens", panel.ItemRows);
|
|
Assert.Equal(2, panel.ConsumableRows.Count);
|
|
panel.RemoveSelectedConsumable();
|
|
Assert.Single(panel.ConsumableRows);
|
|
}
|
|
|
|
[Fact]
|
|
public void ItemProfilesPersistThroughHostScopedStorage()
|
|
{
|
|
var storage = new MemoryStorage();
|
|
var firstAutomation = new FakeAutomation
|
|
{
|
|
ItemEntries = [Item(10, "Imperil Lens", 0x8000)],
|
|
};
|
|
var firstHost = new FakeHost(firstAutomation, storage);
|
|
var first = new MossTankPanel(firstHost);
|
|
firstHost.Selection.Select(10);
|
|
first.AddSelectedItem();
|
|
|
|
var second = new MossTankPanel(new FakeHost(
|
|
new FakeAutomation(),
|
|
storage));
|
|
|
|
Assert.Contains("Imperil Lens", second.ItemProfileText, StringComparison.Ordinal);
|
|
Assert.Contains(
|
|
storage.Text.Keys,
|
|
key => key.StartsWith("profiles/macro/", StringComparison.Ordinal));
|
|
}
|
|
|
|
[Fact]
|
|
public void VitalThresholdsPersistWithTheProfile()
|
|
{
|
|
var storage = new MemoryStorage();
|
|
var first = new MossTankPanel(new FakeHost(new FakeAutomation(), storage));
|
|
first.SetNormalHealth(0.42f);
|
|
first.SetNoTargetMana(0.88f);
|
|
first.ToggleHelpOthers();
|
|
|
|
var second = new MossTankPanel(
|
|
new FakeHost(new FakeAutomation(), storage));
|
|
|
|
Assert.Equal(0.42f, second.NormalHealthValue, precision: 2);
|
|
Assert.Equal(0.88f, second.NoTargetManaValue, precision: 2);
|
|
Assert.False(second.HelpOthersEnabled);
|
|
}
|
|
|
|
[Fact]
|
|
public void AutoStackAndAutoCramUseVtankDefaultsAndPersistPerProfile()
|
|
{
|
|
var storage = new MemoryStorage();
|
|
var first = new MossTankPanel(new FakeHost(new FakeAutomation(), storage));
|
|
|
|
Assert.True(first.AutoStackEnabled);
|
|
Assert.False(first.AutoCramEnabled);
|
|
Assert.True(first.AutoCraftItemsEnabled);
|
|
Assert.True(first.RefillWornManaEnabled);
|
|
first.ToggleAutoStack();
|
|
first.ToggleAutoCram();
|
|
first.ToggleAutoCraftItems();
|
|
first.ToggleRefillWornMana();
|
|
first.SetRefillWornMana(0.44f);
|
|
|
|
var second = new MossTankPanel(new FakeHost(new FakeAutomation(), storage));
|
|
Assert.False(second.AutoStackEnabled);
|
|
Assert.True(second.AutoCramEnabled);
|
|
Assert.False(second.AutoCraftItemsEnabled);
|
|
Assert.False(second.RefillWornManaEnabled);
|
|
Assert.Equal(0.44f, second.RefillWornManaValue, precision: 2);
|
|
}
|
|
|
|
[Fact]
|
|
public void LootingUsesVtankDefaultsAndPersistsTheOrderedRuleEditor()
|
|
{
|
|
var storage = new MemoryStorage();
|
|
var first = new MossTankPanel(new FakeHost(new FakeAutomation(), storage));
|
|
|
|
Assert.False(first.LootEnabled);
|
|
Assert.False(first.LootPriorityBoostEnabled);
|
|
Assert.Empty(first.LootRuleRows);
|
|
first.ToggleLooting();
|
|
first.ToggleLootPriorityBoost();
|
|
first.AddLootRule();
|
|
first.SetLootExpressionDraft("name ~= coin && value >= 10");
|
|
first.ApplyLootRule();
|
|
first.SelectLootAction(nameof(LootAction.KeepUpTo));
|
|
first.LootKeepCountUp();
|
|
first.LootPriorityUp();
|
|
first.LootRangeDown();
|
|
|
|
var second = new MossTankPanel(new FakeHost(new FakeAutomation(), storage));
|
|
Assert.True(second.LootEnabled);
|
|
Assert.True(second.LootPriorityBoostEnabled);
|
|
Assert.Single(second.LootRuleRows);
|
|
Assert.Contains("KeepUpTo", second.LootRuleRows[0], StringComparison.Ordinal);
|
|
Assert.Contains("name ~= coin", second.LootRuleRows[0], StringComparison.Ordinal);
|
|
Assert.Equal("Keep up to 2", second.LootKeepCountText);
|
|
Assert.Equal("Priority 1", second.LootPriorityText);
|
|
Assert.Equal("Corpse range 38m", second.LootRangeText);
|
|
}
|
|
|
|
[Fact]
|
|
public void LootProfilesAreIndependentNamedDocuments()
|
|
{
|
|
var storage = new MemoryStorage();
|
|
var panel = new MossTankPanel(new FakeHost(
|
|
new FakeAutomation { Name = "Looter" },
|
|
storage));
|
|
panel.AddLootRule();
|
|
panel.SetLootExpressionDraft("name ~= coin");
|
|
panel.ApplyLootRule();
|
|
panel.SetLootProfileNameDraft("Currency");
|
|
panel.CopyLootProfile();
|
|
panel.AddLootRule();
|
|
|
|
Assert.Equal("Currency", panel.LootProfileName);
|
|
Assert.Equal(2, panel.LootRuleRows.Count);
|
|
panel.SelectLootProfile(MossTankLootProfileStore.ByCharacter);
|
|
Assert.Single(panel.LootRuleRows);
|
|
panel.SelectLootProfile("Currency");
|
|
Assert.Equal(2, panel.LootRuleRows.Count);
|
|
Assert.Contains(
|
|
storage.Text.Keys,
|
|
key => key.StartsWith("profiles/loot/", StringComparison.Ordinal));
|
|
}
|
|
|
|
[Fact]
|
|
public void LootClassifierSelectionIsVisibleAndPersistsWithMacroProfile()
|
|
{
|
|
var storage = new MemoryStorage();
|
|
var classifiers = new FakeLootClassifierRegistry(
|
|
new PluginLootClassifierInfo("utility/loot", "Utility Loot"));
|
|
var panel = new MossTankPanel(new FakeHost(
|
|
new FakeAutomation { Name = "Looter" },
|
|
storage,
|
|
classifiers));
|
|
|
|
Assert.Contains("Utility Loot [utility/loot]", panel.LootClassifierNames);
|
|
panel.SelectLootClassifier("Utility Loot [utility/loot]");
|
|
|
|
Assert.Equal(
|
|
"Utility Loot [utility/loot]",
|
|
panel.SelectedLootClassifier);
|
|
var restored = new MossTankPanel(new FakeHost(
|
|
new FakeAutomation { Name = "Looter" },
|
|
storage,
|
|
classifiers));
|
|
Assert.Equal(
|
|
"Utility Loot [utility/loot]",
|
|
restored.SelectedLootClassifier);
|
|
|
|
restored.SelectLootClassifier("VTClassic");
|
|
Assert.Equal("VTClassic", restored.SelectedLootClassifier);
|
|
}
|
|
|
|
[Fact]
|
|
public void VtankRecoveryAndFakeImperilCommandsHaveRealLocalSemantics()
|
|
{
|
|
var automation = new FakeAutomation
|
|
{
|
|
BusyReferences = 2,
|
|
WorldObjects =
|
|
[
|
|
new PluginWorldObject(
|
|
0x50000001u,
|
|
123u,
|
|
"Drudge",
|
|
PluginObjectClass.Monster,
|
|
0u,
|
|
0u,
|
|
0u),
|
|
],
|
|
};
|
|
var host = new FakeHost(automation);
|
|
var panel = new MossTankPanel(host);
|
|
|
|
Command(panel, "clearbusy");
|
|
Assert.Equal(1, automation.BusyReferences);
|
|
Assert.Contains(automation.Messages,
|
|
text => text.Contains("2 -> 1", StringComparison.Ordinal));
|
|
|
|
Command(panel, "clearlocks");
|
|
Assert.Contains(automation.Messages,
|
|
text => text.Contains("Action locks cleared", StringComparison.Ordinal));
|
|
|
|
host.Selection.Select(0x50000001u);
|
|
Command(panel, "fakeimp");
|
|
Assert.Contains(automation.Messages,
|
|
text => text.Contains("Fake cast complete", StringComparison.Ordinal));
|
|
}
|
|
|
|
[Fact]
|
|
public void LootCommandsImportAndExportExactVtclassicUtlFiles()
|
|
{
|
|
var storage = new MemoryStorage();
|
|
var legacy = new VtankLootProfile
|
|
{
|
|
Rules =
|
|
[
|
|
new LootRule
|
|
{
|
|
Name = "Pyreal",
|
|
Action = LootAction.KeepUpTo,
|
|
KeepCount = 100,
|
|
Priority = 7,
|
|
VtankRequirements =
|
|
[
|
|
new VtankLootRequirement
|
|
{
|
|
Type = 1,
|
|
Payload = "^Pyreal$\r\n1\r\n",
|
|
},
|
|
],
|
|
},
|
|
],
|
|
SalvageCombine = new VtankSalvageCombineSettings
|
|
{
|
|
DefaultCombineString = "1-5, 6-10",
|
|
MaterialCombineStrings = new Dictionary<int, string>
|
|
{
|
|
[61] = "1-10",
|
|
},
|
|
MaterialValueModeValues = new Dictionary<int, int>
|
|
{
|
|
[61] = 75_000,
|
|
},
|
|
},
|
|
};
|
|
storage.Text["imports/Legacy.utl"] =
|
|
VtankLootProfileSerializer.Write(legacy);
|
|
var panel = new MossTankPanel(new FakeHost(
|
|
new FakeAutomation(),
|
|
storage));
|
|
|
|
Command(panel, "loot load Legacy.utl");
|
|
|
|
Assert.Equal("Legacy", panel.LootProfileName);
|
|
Assert.Single(panel.LootRuleRows);
|
|
Assert.Contains("KeepUpTo", panel.LootRuleRows[0], StringComparison.Ordinal);
|
|
string exported = storage.Text["exports/Legacy.utl"];
|
|
Assert.True(VtankLootProfileSerializer.TryRead(
|
|
exported,
|
|
out VtankLootProfile roundTrip,
|
|
out string error), error);
|
|
Assert.Equal("1-5, 6-10", roundTrip.SalvageCombine.DefaultCombineString);
|
|
Assert.Equal(75_000, roundTrip.SalvageCombine.MaterialValueModeValues[61]);
|
|
Assert.Equal(1, Assert.Single(roundTrip.Rules).VtankRequirements[0].Type);
|
|
}
|
|
|
|
[Fact]
|
|
public void NamedProfileCopyHotLoadsWithoutMixingByCharacterSettings()
|
|
{
|
|
var storage = new MemoryStorage();
|
|
var automation = new FakeAutomation { Name = "Moss Wart" };
|
|
var panel = new MossTankPanel(new FakeHost(automation, storage));
|
|
panel.SetNormalHealth(0.42f);
|
|
panel.SetProfileNameDraft("Fellowship");
|
|
panel.CopyProfile();
|
|
|
|
Assert.Equal("Fellowship", panel.SelectedMacroProfile);
|
|
Assert.Contains("Fellowship", panel.MacroProfileNames);
|
|
panel.SetNormalHealth(0.88f);
|
|
panel.SelectMacroProfile(MossTankProfileStore.ByCharacter);
|
|
|
|
Assert.Equal(0.42f, panel.NormalHealthValue, precision: 2);
|
|
panel.SelectMacroProfile("Fellowship");
|
|
Assert.Equal(0.88f, panel.NormalHealthValue, precision: 2);
|
|
}
|
|
|
|
[Fact]
|
|
public void NavCommandsImportAndExportExactVtankNavFiles()
|
|
{
|
|
var storage = new MemoryStorage();
|
|
storage.Text["imports/Legacy.nav"] = """
|
|
uTank2 NAV 1.2
|
|
4
|
|
1
|
|
0
|
|
12.5
|
|
-3.25
|
|
0
|
|
0
|
|
""";
|
|
var panel = new MossTankPanel(new FakeHost(
|
|
new FakeAutomation(),
|
|
storage));
|
|
|
|
Command(panel, "nav load Legacy.nav");
|
|
|
|
Assert.Equal("Legacy", panel.SelectedRouteProfile);
|
|
Assert.Single(panel.RouteRows);
|
|
Assert.Contains("12.5", panel.RouteRows[0], StringComparison.Ordinal);
|
|
|
|
// .af is the only VTank-compatible export format now (Campaign VT
|
|
// slice 1 Part A — the .nav writer was deleted, one-shot import
|
|
// only).
|
|
Command(panel, "nav save Exported.nav");
|
|
Assert.StartsWith(
|
|
"NAV: ",
|
|
storage.Text["exports/nav/Exported.af"],
|
|
StringComparison.Ordinal);
|
|
}
|
|
|
|
// Item C (Campaign VT slice-1 fix round): a Meta profile and a route
|
|
// (Navigation) profile named identically used to write into the SAME
|
|
// flat "exports/" directory — "Same.af" from one silently clobbered
|
|
// "Same.af" from the other. exports/meta/ and exports/nav/ are
|
|
// separate subdirectories so both coexist.
|
|
[Fact]
|
|
public void MetaAndRouteExportsWithTheSameNameDoNotCollide()
|
|
{
|
|
var storage = new MemoryStorage();
|
|
storage.Text["imports/Same.met"] =
|
|
"1\r\nCondAct\r\n5\r\nCType\r\nAType\r\nCData\r\nAData\r\nState\r\n"
|
|
+ "n\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 imported\r\n"
|
|
+ "s\r\nDefault\r\n";
|
|
var panel = new MossTankPanel(new FakeHost(new FakeAutomation(), storage));
|
|
|
|
Command(panel, "meta load Same.met");
|
|
Command(panel, "meta save Same.met");
|
|
Command(panel, "nav save Same.nav");
|
|
|
|
Assert.True(storage.Text.ContainsKey("exports/meta/Same.af"));
|
|
Assert.True(storage.Text.ContainsKey("exports/nav/Same.af"));
|
|
Assert.StartsWith(
|
|
"STATE: ",
|
|
storage.Text["exports/meta/Same.af"],
|
|
StringComparison.Ordinal);
|
|
Assert.StartsWith(
|
|
"NAV: ",
|
|
storage.Text["exports/nav/Same.af"],
|
|
StringComparison.Ordinal);
|
|
}
|
|
|
|
[Fact]
|
|
public void MetaCommandsImportAndExportExactVtankMetFiles()
|
|
{
|
|
var storage = new MemoryStorage();
|
|
// Hand-authored CondAct binary payload (one rule: Always -> Chat
|
|
// "/say imported", state "Default") — VtankMetaProfileSerializer's
|
|
// writer was deleted (one-shot import only now), so this is built
|
|
// directly from the exact format its TryLoad still parses, matching
|
|
// condition type 1 (Always) / action type 2 (ChatCommand).
|
|
storage.Text["imports/Legacy.met"] =
|
|
"1\r\nCondAct\r\n5\r\nCType\r\nAType\r\nCData\r\nAData\r\nState\r\n"
|
|
+ "n\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 imported\r\n"
|
|
+ "s\r\nDefault\r\n";
|
|
var panel = new MossTankPanel(new FakeHost(
|
|
new FakeAutomation(),
|
|
storage));
|
|
|
|
Command(panel, "meta load Legacy.met");
|
|
|
|
Assert.Equal("Legacy", panel.SelectedMetaProfile);
|
|
Assert.Single(panel.MetaRows);
|
|
Assert.Contains("/say imported", panel.MetaRows[0], StringComparison.Ordinal);
|
|
|
|
// .af is the only VTank-compatible export format now (Campaign VT
|
|
// slice 1 Part A — the .met writer was deleted, one-shot import
|
|
// only).
|
|
Command(panel, "meta save Exported.met");
|
|
Assert.StartsWith(
|
|
"STATE: ",
|
|
storage.Text["exports/meta/Exported.af"],
|
|
StringComparison.Ordinal);
|
|
Assert.True(MetafSerializer.TryLoadMeta(
|
|
storage.Text["exports/meta/Exported.af"],
|
|
NoOpSpellCatalogForExport.Instance,
|
|
out MetaProfile exported,
|
|
out string error), error);
|
|
Assert.Single(exported.Rules);
|
|
}
|
|
|
|
private sealed class NoOpSpellCatalogForExport : ISpellCatalog
|
|
{
|
|
public static NoOpSpellCatalogForExport Instance { get; } = new();
|
|
public IReadOnlyList<PluginSpellInfo> KnownSelfBuffs => [];
|
|
public bool TryGet(uint spellId, out PluginSpellInfo info)
|
|
{
|
|
info = default;
|
|
return false;
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void ByCharacterProfilesAreIsolatedByCharacterName()
|
|
{
|
|
var storage = new MemoryStorage();
|
|
var first = new MossTankPanel(new FakeHost(
|
|
new FakeAutomation { Name = "One" },
|
|
storage));
|
|
first.SetNormalHealth(0.33f);
|
|
|
|
var second = new MossTankPanel(new FakeHost(
|
|
new FakeAutomation { Name = "Two" },
|
|
storage));
|
|
|
|
Assert.Equal(0.75f, second.NormalHealthValue, precision: 2);
|
|
}
|
|
|
|
[Fact]
|
|
public void MonstersEditorMutatesAndPersistsExecutableOrderedRules()
|
|
{
|
|
var storage = new MemoryStorage();
|
|
var first = new MossTankPanel(new FakeHost(
|
|
new FakeAutomation { Name = "Rule Maker" },
|
|
storage));
|
|
|
|
first.AddMonsterRule();
|
|
first.SetMonsterExpressionDraft("species==drudge");
|
|
first.ApplyMonsterRule();
|
|
first.ToggleMonsterImperil();
|
|
first.SelectMonsterDamage(nameof(MonsterDamageType.Fire));
|
|
first.MonsterPriorityUp();
|
|
|
|
Assert.Equal(1, first.SelectedMonsterRuleIndex);
|
|
Assert.Equal(nameof(MonsterDamageType.Fire), first.SelectedDamageType);
|
|
Assert.True(first.MonsterImperil);
|
|
Assert.Contains("species==drudge", first.MonsterRows[1], StringComparison.Ordinal);
|
|
|
|
var second = new MossTankPanel(new FakeHost(
|
|
new FakeAutomation { Name = "Rule Maker" },
|
|
storage));
|
|
second.SelectMonsterRule(1);
|
|
|
|
Assert.Equal(nameof(MonsterDamageType.Fire), second.SelectedDamageType);
|
|
Assert.True(second.MonsterImperil);
|
|
Assert.Equal("Priority 1", second.MonsterPriorityText);
|
|
}
|
|
|
|
[Fact]
|
|
public void MonsterColumnsExposeRetailSpecificDamageCyclesAndLabels()
|
|
{
|
|
var panel = new MossTankPanel(new FakeHost(new FakeAutomation()));
|
|
|
|
Assert.Equal(
|
|
[
|
|
"Pierce", "Bludgeon", "Slash", "Acid", "Lightning", "Cold",
|
|
"Fire", "Harm", "Auto", "Void Basic", "Drain Auto",
|
|
"Prismatic", "Random", "Fists",
|
|
],
|
|
panel.DamageTypeNames);
|
|
Assert.Equal(
|
|
[
|
|
"Pierce", "Bludgeon", "Slash", "Acid", "Lightning", "Cold",
|
|
"Fire", "Auto", "None",
|
|
],
|
|
panel.ExtraVulnerabilityNames);
|
|
Assert.Equal(
|
|
[
|
|
"Pierce", "Bludgeon", "Slash", "Acid", "Lightning", "Cold",
|
|
"Fire", "PAuto", "Auto", "None",
|
|
],
|
|
panel.PetDamageTypeNames);
|
|
|
|
panel.SelectMonsterDamage("Void Basic");
|
|
panel.SelectMonsterExtraVulnerability("Lightning");
|
|
panel.SelectMonsterPetDamage("PAuto");
|
|
|
|
Assert.Equal("Void Basic", panel.SelectedDamageType);
|
|
Assert.Equal("Lightning", panel.SelectedExtraVulnerability);
|
|
Assert.Equal("PAuto", panel.SelectedPetDamage);
|
|
}
|
|
|
|
[Fact]
|
|
public void MonsterEquipmentPersistsByNameAcrossSessionObjectIds()
|
|
{
|
|
var storage = new MemoryStorage();
|
|
var firstAutomation = new FakeAutomation
|
|
{
|
|
Name = "Rule Maker",
|
|
ItemEntries = [Item(10, "Fire Sword", 1)],
|
|
};
|
|
var firstHost = new FakeHost(firstAutomation, storage);
|
|
var first = new MossTankPanel(firstHost);
|
|
firstHost.Selection.Select(10);
|
|
first.SetMonsterWeapon();
|
|
|
|
var second = new MossTankPanel(new FakeHost(
|
|
new FakeAutomation
|
|
{
|
|
Name = "Rule Maker",
|
|
ItemEntries = [Item(99, "Fire Sword", 1)],
|
|
},
|
|
storage));
|
|
|
|
Assert.Contains("Fire Sword", second.MonsterEquipmentText);
|
|
}
|
|
|
|
[Fact]
|
|
public void MetaTabEditsAndExecutesTheLiveStateMachine()
|
|
{
|
|
var panel = new MossTankPanel(new FakeHost(new FakeAutomation()));
|
|
|
|
panel.ShowMeta();
|
|
Assert.True(panel.MetaVisible);
|
|
panel.SetMetaStateDraft(MetaEngine.DefaultState);
|
|
panel.SelectMetaCondition(nameof(MetaConditionKind.Always));
|
|
panel.SelectMetaAction(nameof(MetaActionKind.SetMetaState));
|
|
panel.SetMetaActionTextDraft("Hunt");
|
|
panel.AddMetaRule();
|
|
|
|
Assert.Single(panel.MetaRows);
|
|
Assert.Contains("Hunt", panel.MetaRows[0], StringComparison.Ordinal);
|
|
panel.ToggleMeta();
|
|
panel.ToggleCombat();
|
|
panel.OnTick(0.3);
|
|
|
|
Assert.True(panel.MetaEnabled);
|
|
Assert.Equal("Hunt", panel.MetaState);
|
|
}
|
|
|
|
[Fact]
|
|
public void MetaProfilesAreIndependentDurableDocuments()
|
|
{
|
|
var storage = new MemoryStorage();
|
|
var first = new MossTankPanel(new FakeHost(
|
|
new FakeAutomation { Name = "Meta Maker" },
|
|
storage));
|
|
first.SelectMetaAction(nameof(MetaActionKind.ChatCommand));
|
|
first.SetMetaActionTextDraft("/mt status");
|
|
first.AddMetaRule();
|
|
first.SetMetaProfileNameDraft("Hunting");
|
|
first.CopyMetaProfile();
|
|
|
|
Assert.Equal("Hunting", first.SelectedMetaProfile);
|
|
Assert.Single(first.MetaRows);
|
|
first.SelectMetaProfile(MossTankMetaProfileStore.ByCharacter);
|
|
first.AddMetaRule();
|
|
Assert.Equal(2, first.MetaRows.Count);
|
|
|
|
var second = new MossTankPanel(new FakeHost(
|
|
new FakeAutomation { Name = "Meta Maker" },
|
|
storage));
|
|
Assert.Equal(MossTankMetaProfileStore.ByCharacter, second.SelectedMetaProfile);
|
|
Assert.Equal(2, second.MetaRows.Count);
|
|
second.SelectMetaProfile("Hunting");
|
|
Assert.Single(second.MetaRows);
|
|
Assert.Contains("/mt status", second.MetaRows[0], StringComparison.Ordinal);
|
|
}
|
|
|
|
[Fact]
|
|
public void UtilityBeltVtankExpressionsControlTheSameLiveOwners()
|
|
{
|
|
var panel = new MossTankPanel(new FakeHost(new FakeAutomation()));
|
|
panel.SelectMetaCondition(nameof(MetaConditionKind.Expression));
|
|
panel.SetMetaConditionTextDraft("vtmacroenabled[] == 1");
|
|
panel.SelectMetaAction(nameof(MetaActionKind.ExpressionAction));
|
|
panel.SetMetaActionTextDraft(
|
|
"vtsetsetting['MonsterRange',42] + vtsetmetastate['Expression State']");
|
|
panel.AddMetaRule();
|
|
panel.ToggleMeta();
|
|
panel.ToggleCombat();
|
|
|
|
panel.OnTick(0.3);
|
|
|
|
Assert.Equal("Expression State", panel.MetaState);
|
|
Assert.Equal("Maximum target range: 42m", panel.AttackRangeText);
|
|
Assert.True(panel.EvaluateExpression("uboptset['MonsterRange',33]").IsTruthy);
|
|
Assert.Equal(33d, panel.EvaluateExpression(
|
|
"uboptget['MonsterRange']").AsNumber());
|
|
}
|
|
|
|
[Fact]
|
|
public void RunMacroAndEnableCombatAreIndependentVtankStates()
|
|
{
|
|
var panel = new MossTankPanel(new FakeHost(new FakeAutomation()));
|
|
|
|
Assert.Equal("Run Macro", panel.CombatButtonText);
|
|
Assert.True(panel.CombatEnabled);
|
|
|
|
panel.ToggleCombat();
|
|
panel.ToggleCombatEnabled();
|
|
panel.OnTick(0d);
|
|
|
|
Assert.Equal("Stop Macro", panel.CombatButtonText);
|
|
Assert.False(panel.CombatEnabled);
|
|
Assert.Equal("Combat disabled", panel.CombatStatus);
|
|
Assert.True(panel.EvaluateExpression("vtmacroenabled[]").IsTruthy);
|
|
Assert.False(panel.EvaluateExpression("uboptget['EnableCombat']").IsTruthy);
|
|
}
|
|
|
|
[Fact]
|
|
public void SameCharacterReconnectClearsOnlySessionStateAndCanRestartCleanly()
|
|
{
|
|
var automation = new FakeAutomation { Name = "Relogger" };
|
|
var panel = new MossTankPanel(new FakeHost(automation));
|
|
panel.ToggleMeta();
|
|
panel.ToggleCombat();
|
|
panel.EvaluateExpression(
|
|
"$session=11;@persistent=22;&global=33;"
|
|
+ "delayexec[60000,\"$late=1\"]");
|
|
panel.EvaluateExpression("vtsetmetastate['Hunt']");
|
|
|
|
automation.IsAvailable = false;
|
|
panel.OnTick(0.1d);
|
|
|
|
Assert.Equal("Run Macro", panel.CombatButtonText);
|
|
Assert.False(panel.MetaEnabled);
|
|
Assert.Equal(MetaEngine.DefaultState, panel.MetaState);
|
|
Assert.Equal(0d, panel.EvaluateExpression("$session").AsNumber());
|
|
Assert.Equal(22d, panel.EvaluateExpression("@persistent").AsNumber());
|
|
Assert.Equal(33d, panel.EvaluateExpression("&global").AsNumber());
|
|
Assert.Equal("Lost the session.", panel.BuffStatus);
|
|
|
|
automation.IsAvailable = true;
|
|
panel.OnTick(0.1d);
|
|
panel.OnTick(61d);
|
|
|
|
Assert.Equal(0d, panel.EvaluateExpression("$late").AsNumber());
|
|
Assert.Equal("Idle.", panel.BuffStatus);
|
|
panel.ToggleCombat();
|
|
Assert.Equal("Stop Macro", panel.CombatButtonText);
|
|
}
|
|
|
|
[Fact]
|
|
public void OfficialVtankOptionDefaultsAndDynamicOverridesAreDurable()
|
|
{
|
|
var storage = new MemoryStorage();
|
|
var first = new MossTankPanel(new FakeHost(new FakeAutomation(), storage));
|
|
|
|
Assert.Equal(137, VtankOptionCatalog.Names.Length);
|
|
Assert.Equal(10d, first.EvaluateExpression(
|
|
"uboptget['ArrowheadFletchDiffExcessThreshold']").AsNumber());
|
|
Assert.Equal(0.0833333333333333d, first.EvaluateExpression(
|
|
"uboptget['DoorIDRange']").AsNumber(), precision: 14);
|
|
Assert.True(first.EvaluateExpression(
|
|
"uboptget['ManaChargesWhenOff']").IsTruthy);
|
|
Assert.Equal(4d, first.EvaluateExpression(
|
|
"uboptget['SpellCompMin-Critical']").AsNumber());
|
|
Assert.Equal(20d, first.EvaluateExpression(
|
|
"uboptget['SpellCompMin-Normal']").AsNumber());
|
|
Assert.Equal(20d, first.EvaluateExpression(
|
|
"uboptget['SpellCompMin-Idle']").AsNumber());
|
|
Assert.Equal(2d, first.EvaluateExpression(
|
|
"uboptget['IdleCraftCount_HealthKits']").AsNumber());
|
|
Assert.Equal(15d, first.EvaluateExpression(
|
|
"uboptget['IdleCraftCount_ManaFood']").AsNumber());
|
|
Assert.True(first.EvaluateExpression(
|
|
"uboptset['ArrowheadFletchDiffExcessThreshold',22]").IsTruthy);
|
|
Assert.True(first.EvaluateExpression(
|
|
"uboptset['IdleCraftCount_HealthKits',7]").IsTruthy);
|
|
|
|
var second = new MossTankPanel(new FakeHost(new FakeAutomation(), storage));
|
|
Assert.Equal(22d, second.EvaluateExpression(
|
|
"uboptget['arrowheadfletchdiffexcessthreshold']").AsNumber());
|
|
Assert.Equal(7d, second.EvaluateExpression(
|
|
"uboptget['idlecraftcount_healthkits']").AsNumber());
|
|
}
|
|
|
|
[Fact]
|
|
public void VtankSetInAllRewritesEveryKnownMacroProfile()
|
|
{
|
|
var storage = new MemoryStorage();
|
|
var automation = new FakeAutomation();
|
|
var panel = new MossTankPanel(new FakeHost(automation, storage));
|
|
|
|
Command(panel, "settings save First");
|
|
Command(panel, "opt set AttackDistance 0.01");
|
|
Command(panel, "settings save Second");
|
|
Command(panel, "opt set AttackDistance 0.02");
|
|
Command(panel, "opt setinall AttackDistance 0.03");
|
|
Command(panel, "settings load First");
|
|
Assert.Equal(0.03d, panel.EvaluateExpression(
|
|
"uboptget['AttackDistance']").AsNumber(), precision: 7);
|
|
Command(panel, "settings load Second");
|
|
Assert.Equal(0.03d, panel.EvaluateExpression(
|
|
"uboptget['AttackDistance']").AsNumber(), precision: 7);
|
|
|
|
var reloaded = new MossTankPanel(new FakeHost(
|
|
new FakeAutomation(), storage));
|
|
Command(reloaded, "settings load First");
|
|
Assert.Equal(0.03d, reloaded.EvaluateExpression(
|
|
"uboptget['AttackDistance']").AsNumber(), precision: 7);
|
|
}
|
|
|
|
[Fact]
|
|
public void VtankHelpAndExpressionsUseTheLocalCommandSurface()
|
|
{
|
|
var automation = new FakeAutomation();
|
|
var panel = new MossTankPanel(new FakeHost(automation));
|
|
|
|
panel.ExecuteVtankCommand(new PluginCommand("vt", "help", "/vt help"));
|
|
panel.ExecuteVtankCommand(new PluginCommand(
|
|
"vt",
|
|
"mexec 1 + 2 * 3",
|
|
"/vt mexec 1 + 2 * 3"));
|
|
|
|
Assert.Equal(6, automation.Messages.Count);
|
|
Assert.StartsWith("/vt commands (profiles):", automation.Messages[0],
|
|
StringComparison.Ordinal);
|
|
Assert.Equal("MExec evaluating expression: \"1 + 2 * 3\"", automation.Messages[4]);
|
|
Assert.Equal("Result: 7", automation.Messages[5]);
|
|
}
|
|
|
|
[Fact]
|
|
public void VtankJumpTurnsToTheRequestedHeadingBeforeChargingAndReleasing()
|
|
{
|
|
var automation = new FakeAutomation
|
|
{
|
|
NavigationSnapshot = NavigationAt(90f),
|
|
};
|
|
var panel = new MossTankPanel(new FakeHost(automation));
|
|
|
|
panel.ExecuteVtankCommand(new PluginCommand(
|
|
"vt", "jump 180 false 100", "/vt jump 180 false 100"));
|
|
panel.OnTick(0.05d);
|
|
|
|
Assert.True(automation.MovementIntents[^1].TurnRight);
|
|
Assert.False(automation.MovementIntents[^1].Jump);
|
|
|
|
automation.NavigationSnapshot = NavigationAt(180f);
|
|
panel.OnTick(0.01d);
|
|
Assert.True(automation.MovementIntents[^1].Jump);
|
|
|
|
panel.OnTick(0.1d);
|
|
Assert.False(automation.MovementIntents[^1].Jump);
|
|
}
|
|
|
|
[Fact]
|
|
public void RegistersEveryAuditedUtilityBeltExpressionFunction()
|
|
{
|
|
var panel = new MossTankPanel(new FakeHost(new FakeAutomation()));
|
|
string[] expected =
|
|
[
|
|
"abs",
|
|
"acos",
|
|
"actiontryapplyitem",
|
|
"actiontrycastbyid",
|
|
"actiontrycastbyidontarget",
|
|
"actiontrydrop",
|
|
"actiontryequipanywand",
|
|
"actiontrygiveitem",
|
|
"actiontrygiveprofile",
|
|
"actiontrymove",
|
|
"actiontryselect",
|
|
"actiontrysplit",
|
|
"actiontryuseitem",
|
|
"asin",
|
|
"atan",
|
|
"atan2",
|
|
"ceiling",
|
|
"chatbox",
|
|
"chatboxpaste",
|
|
"chr",
|
|
"clearallgvars",
|
|
"clearallpvars",
|
|
"clearallvars",
|
|
"clearexec",
|
|
"cleargvar",
|
|
"clearmotion",
|
|
"clearnextlogin",
|
|
"clearpvar",
|
|
"clearvar",
|
|
"cnumber",
|
|
"componentdata",
|
|
"componentname",
|
|
"coordinatedistanceflat",
|
|
"coordinatedistancewithz",
|
|
"coordinategetns",
|
|
"coordinategetwe",
|
|
"coordinategetz",
|
|
"coordinateparse",
|
|
"coordinatetostring",
|
|
"cos",
|
|
"cosh",
|
|
"cstr",
|
|
"cstrf",
|
|
"delayexec",
|
|
"dictadditem",
|
|
"dictclear",
|
|
"dictcopy",
|
|
"dictcreate",
|
|
"dictgetitem",
|
|
"dicthaskey",
|
|
"dictkeys",
|
|
"dictremovekey",
|
|
"dictsize",
|
|
"dictvalues",
|
|
"echo",
|
|
"exec",
|
|
"floor",
|
|
"getaccounthash",
|
|
"getbusystate",
|
|
"getcancastspell_buff",
|
|
"getcancastspell_hunt",
|
|
"getcharacterindex",
|
|
"getcharattribute_base",
|
|
"getcharattribute_buffed",
|
|
"getcharboolprop",
|
|
"getcharburden",
|
|
"getchardoubleprop",
|
|
"getcharintprop",
|
|
"getcharquadprop",
|
|
"getcharskill_base",
|
|
"getcharskill_buffed",
|
|
"getcharskill_traininglevel",
|
|
"getcharstringprop",
|
|
"getcharvital_base",
|
|
"getcharvital_buffedmax",
|
|
"getcharvital_current",
|
|
"getcombatstate",
|
|
"getcontaineritemcount",
|
|
"getcooldownexpiration",
|
|
"getcorpsesunopenedbyme",
|
|
"getdatetimelocal",
|
|
"getdatetimeutc",
|
|
"getequippedweapontype",
|
|
"getfellowid",
|
|
"getfellowids",
|
|
"getfellowname",
|
|
"getfellownames",
|
|
"getfellowshipcanrecruit",
|
|
"getfellowshipcount",
|
|
"getfellowshipisfull",
|
|
"getfellowshipisleader",
|
|
"getfellowshipisopen",
|
|
"getfellowshipleaderid",
|
|
"getfellowshiplocked",
|
|
"getfellowshipname",
|
|
"getfellowshipstatus",
|
|
"getfreecontainerslots",
|
|
"getfreeitemslots",
|
|
"getgameday",
|
|
"getgamehour",
|
|
"getgamehourname",
|
|
"getgamemonth",
|
|
"getgamemonthname",
|
|
"getgameticks",
|
|
"getgameyear",
|
|
"getgvar",
|
|
"getheading",
|
|
"getheadingto",
|
|
"getinventorycountbytemplatetype",
|
|
"getisday",
|
|
"getisnight",
|
|
"getisspellknown",
|
|
"getitemcountininventorybyname",
|
|
"getitemcountininventorybynamerx",
|
|
"getknownspells",
|
|
"getminutesuntilday",
|
|
"getminutesuntilnight",
|
|
"getmotion",
|
|
"getobjectinternaltype",
|
|
"getplayercoordinates",
|
|
"getplayerlandblock",
|
|
"getplayerlandcell",
|
|
"getpvar",
|
|
"getquestktprogress",
|
|
"getquestktrequired",
|
|
"getqueststatus",
|
|
"getregexmatch",
|
|
"getspellexpiration",
|
|
"getspellexpirationbyname",
|
|
"getunixtime",
|
|
"getvar",
|
|
"getworldname",
|
|
"hascorpsebeenopenedbyme",
|
|
"hexstr",
|
|
"ifthen",
|
|
"iif",
|
|
"isfalse",
|
|
"isportaling",
|
|
"isrefreshingquests",
|
|
"istrue",
|
|
"listadd",
|
|
"listclear",
|
|
"listcontains",
|
|
"listcopy",
|
|
"listcount",
|
|
"listcreate",
|
|
"listfilter",
|
|
"listfromrange",
|
|
"listgetitem",
|
|
"listindexof",
|
|
"listinsert",
|
|
"listlastindexof",
|
|
"listmap",
|
|
"listpop",
|
|
"listreduce",
|
|
"listremove",
|
|
"listremoveat",
|
|
"listreverse",
|
|
"listsort",
|
|
"lumavg",
|
|
"lumtotal",
|
|
"netclients",
|
|
"ord",
|
|
"randint",
|
|
"round",
|
|
"setcombatstate",
|
|
"setgvar",
|
|
"setmotion",
|
|
"setnextlogin",
|
|
"setpvar",
|
|
"setvar",
|
|
"sin",
|
|
"sinh",
|
|
"spelldata",
|
|
"spellname",
|
|
"sqrt",
|
|
"statushud",
|
|
"statushudcolored",
|
|
"stopwatchcreate",
|
|
"stopwatchelapsedseconds",
|
|
"stopwatchstart",
|
|
"stopwatchstop",
|
|
"strlen",
|
|
"tan",
|
|
"tanh",
|
|
"testgvar",
|
|
"testpvar",
|
|
"testquestflag",
|
|
"testvar",
|
|
"tostring",
|
|
"touchgvar",
|
|
"touchpvar",
|
|
"touchvar",
|
|
"uboptget",
|
|
"uboptset",
|
|
"uigetcontrol",
|
|
"uisetlabel",
|
|
"uisetvisible",
|
|
"uiviewexists",
|
|
"uiviewvisible",
|
|
"ustadd",
|
|
"ustopen",
|
|
"ustsalvage",
|
|
"vitae",
|
|
"vtgetmeta",
|
|
"vtgetmetastate",
|
|
"vtgetsetting",
|
|
"vtmacroenabled",
|
|
"vtsetmetastate",
|
|
"vtsetsetting",
|
|
"wobjectfindall",
|
|
"wobjectfindallbycontainer",
|
|
"wobjectfindallbynamerx",
|
|
"wobjectfindallbyobjectclass",
|
|
"wobjectfindallbytemplatetype",
|
|
"wobjectfindallinventory",
|
|
"wobjectfindallinventorybynamerx",
|
|
"wobjectfindallinventorybyobjectclass",
|
|
"wobjectfindallinventorybytemplatetype",
|
|
"wobjectfindalllandscape",
|
|
"wobjectfindalllandscapebynamerx",
|
|
"wobjectfindalllandscapebyobjectclass",
|
|
"wobjectfindalllandscapebytemplatetype",
|
|
"wobjectfindbyid",
|
|
"wobjectfindininventorybyname",
|
|
"wobjectfindininventorybynamerx",
|
|
"wobjectfindininventorybytemplatetype",
|
|
"wobjectfindnearestbynameandobjectclass",
|
|
"wobjectfindnearestbyobjectclass",
|
|
"wobjectfindnearestbytemplatetype",
|
|
"wobjectfindnearestdoor",
|
|
"wobjectfindnearestmonster",
|
|
"wobjectgetactivespellids",
|
|
"wobjectgetboolprop",
|
|
"wobjectgetdoubleprop",
|
|
"wobjectgethealth",
|
|
"wobjectgethealthvalue",
|
|
"wobjectgetid",
|
|
"wobjectgetintprop",
|
|
"wobjectgetisdooropen",
|
|
"wobjectgetmanavalue",
|
|
"wobjectgetname",
|
|
"wobjectgetobjectclass",
|
|
"wobjectgetopencontainer",
|
|
"wobjectgetphysicscoordinates",
|
|
"wobjectgetplayer",
|
|
"wobjectgetselection",
|
|
"wobjectgetspellids",
|
|
"wobjectgetstaminavalue",
|
|
"wobjectgetstringprop",
|
|
"wobjectgettemplatetype",
|
|
"wobjecthasdata",
|
|
"wobjectisvalid",
|
|
"wobjectlastidtime",
|
|
"wobjectrequestdata",
|
|
"xpavg",
|
|
"xpduration",
|
|
"xpmeter",
|
|
"xpreset",
|
|
"xptotal",
|
|
];
|
|
|
|
string[] missing = expected
|
|
.Except(panel.ExpressionFunctionNames, StringComparer.OrdinalIgnoreCase)
|
|
.Order(StringComparer.OrdinalIgnoreCase)
|
|
.ToArray();
|
|
Assert.True(
|
|
missing.Length == 0,
|
|
"Missing UtilityBelt expression functions: " + string.Join(", ", missing));
|
|
}
|
|
|
|
private static PluginInventoryItem Item(
|
|
uint id,
|
|
string name,
|
|
uint itemType) => new(
|
|
id, 0, name, itemType, 1, 0, 0, 0, 0, 0, 0,
|
|
1, 0, 0, 0, 0, 0, 0, false, 0, 0, 0, 0, 0, 0, 0, 0);
|
|
|
|
private static PluginEquipmentItem EquipmentItem(
|
|
uint id,
|
|
string name,
|
|
uint itemType) => new(
|
|
id,
|
|
name,
|
|
ItemType: itemType,
|
|
ValidLocations: 0x00100000,
|
|
EquippedLocation: 0,
|
|
ContainerObjectId: 1,
|
|
WielderObjectId: 0,
|
|
CombatUse: 1,
|
|
DamageType: 0,
|
|
WeaponSkill: 44,
|
|
Damage: 20,
|
|
DamageVariance: 0.25);
|
|
|
|
private static PluginSpellInfo Spell(uint id, uint family, string description) => new(
|
|
id,
|
|
$"Spell {id}",
|
|
family,
|
|
Tier: 1,
|
|
Difficulty: 10,
|
|
ManaCost: 5,
|
|
DurationSeconds: 60f,
|
|
School: 1,
|
|
description,
|
|
IsSelfTargeted: true,
|
|
IsBeneficial: true);
|
|
|
|
private static PluginNavigationSnapshot NavigationAt(float heading) => new(
|
|
IsAvailable: true,
|
|
IsPortalSpace: false,
|
|
LocalObjectId: 1u,
|
|
Position: new PluginNavigationPosition(
|
|
0x00010001u, 0d, 0d, 0d, heading, IsOutdoor: true),
|
|
IsMoving: false,
|
|
IsAirborne: false);
|
|
|
|
private static void Command(MossTankPanel panel, string arguments) =>
|
|
panel.ExecuteVtankCommand(new PluginCommand(
|
|
"vt", arguments, "/vt " + arguments));
|
|
|
|
private sealed class FakeHost(
|
|
IAutomationSurface automation,
|
|
IPluginStorage? storage = null,
|
|
IPluginLootClassifierRegistry? lootClassifiers = 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;
|
|
public IPluginLootClassifierRegistry LootClassifiers { get; } =
|
|
lootClassifiers ?? NoOpPluginLootClassifierRegistry.Instance;
|
|
}
|
|
|
|
private sealed class FakeLootClassifierRegistry(
|
|
params PluginLootClassifierInfo[] available)
|
|
: IPluginLootClassifierRegistry
|
|
{
|
|
public IReadOnlyList<PluginLootClassifierInfo> Available { get; } =
|
|
available;
|
|
}
|
|
|
|
private sealed class FakeAutomation
|
|
: IAutomationSurface, ICharacterInfo, ISpellCatalog, IMagicCommands,
|
|
IPluginChat, IItemAutomation, INavigationAutomation,
|
|
IWorldObjectAutomation, IRecoveryAutomation
|
|
{
|
|
private IReadOnlyList<PluginSpellInfo> _knownSelfBuffs = [];
|
|
|
|
public bool IsAvailable { get; set; } = true;
|
|
public ICharacterInfo Character => this;
|
|
public ISpellCatalog Spells => this;
|
|
public IMagicCommands Magic => this;
|
|
public IPluginChat Chat => this;
|
|
public IItemAutomation Items => this;
|
|
public INavigationAutomation Navigation => this;
|
|
public IWorldObjectAutomation Objects => this;
|
|
public IRecoveryAutomation Recovery => this;
|
|
public bool IsInWorld => IsAvailable;
|
|
public string Name { get; set; } = "Test Character";
|
|
public uint ObjectId { get; set; } = 1;
|
|
public uint CurrentHealth { get; set; }
|
|
public uint MaxHealth { get; set; }
|
|
public uint CurrentStamina { get; set; }
|
|
public uint MaxStamina { get; set; }
|
|
public uint CurrentMana { get; set; }
|
|
public uint MaxMana { get; set; }
|
|
public IReadOnlyList<PluginSkillInfo> Skills { get; set; } = [];
|
|
public IReadOnlyList<PluginAttributeInfo> Attributes { get; set; } = [];
|
|
public IReadOnlyList<PluginActiveEnchantment> ActiveEnchantments { get; set; } = [];
|
|
public IReadOnlyList<PluginInventoryItem> ItemEntries { get; set; } = [];
|
|
public List<string> Messages { get; } = [];
|
|
public List<uint> CastSpellIds { get; } = [];
|
|
public List<PluginMovementIntent> MovementIntents { get; } = [];
|
|
public int ClearMovementCount { get; private set; }
|
|
public IReadOnlyList<PluginWorldObject> WorldObjects { get; set; } = [];
|
|
public int BusyReferences { get; set; }
|
|
public PluginNavigationSnapshot NavigationSnapshot { get; set; }
|
|
public PluginNavigationSnapshot Snapshot => NavigationSnapshot;
|
|
public IReadOnlyList<PluginInventoryItem> CaptureOwnedItems() => ItemEntries;
|
|
public IReadOnlyList<PluginWorldObject> CaptureObjects() => WorldObjects;
|
|
|
|
bool IWorldObjectAutomation.TryGet(
|
|
uint objectId,
|
|
out PluginWorldObject value)
|
|
{
|
|
foreach (PluginWorldObject candidate in WorldObjects)
|
|
{
|
|
if (candidate.ObjectId != objectId)
|
|
continue;
|
|
value = candidate;
|
|
return true;
|
|
}
|
|
value = default;
|
|
return false;
|
|
}
|
|
|
|
public PluginRecoveryResult ClearOneBusyReference()
|
|
{
|
|
int before = BusyReferences;
|
|
BusyReferences = Math.Max(0, BusyReferences - 1);
|
|
return new(true, before, BusyReferences);
|
|
}
|
|
|
|
public int KnownSelfBuffReads { get; private set; }
|
|
|
|
public IReadOnlyList<PluginSpellInfo> KnownSelfBuffs
|
|
{
|
|
get
|
|
{
|
|
KnownSelfBuffReads++;
|
|
return _knownSelfBuffs;
|
|
}
|
|
set => _knownSelfBuffs = value;
|
|
}
|
|
|
|
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 _knownSelfBuffs)
|
|
{
|
|
if (candidate.SpellId == spellId)
|
|
{
|
|
info = candidate;
|
|
return true;
|
|
}
|
|
}
|
|
info = default;
|
|
return false;
|
|
}
|
|
|
|
public bool IsCasting { get; set; }
|
|
public PluginCastGate EvaluateGate(uint spellId) => PluginCastGate.Ready;
|
|
public bool Cast(uint spellId)
|
|
{
|
|
CastSpellIds.Add(spellId);
|
|
return true;
|
|
}
|
|
public void PostSystemMessage(string text) => Messages.Add(text);
|
|
public bool TryGetObject(uint objectId, out PluginNavigationObject value)
|
|
{
|
|
value = default;
|
|
return false;
|
|
}
|
|
public PluginNavigationCommandStatus SetMovementIntent(
|
|
in PluginMovementIntent intent)
|
|
{
|
|
MovementIntents.Add(intent);
|
|
return PluginNavigationCommandStatus.Accepted;
|
|
}
|
|
public PluginNavigationCommandStatus ClearMovementIntent()
|
|
{
|
|
ClearMovementCount++;
|
|
return PluginNavigationCommandStatus.Accepted;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// A combat/equipment-capable automation surface for Test 6 of
|
|
/// docs/plans/2026-09-06-mosstank-mode-arbitration.md — the full
|
|
/// buff-then-fight scenario, which needs real (synchronous) mode/equip
|
|
/// simulation rather than <see cref="FakeAutomation"/>'s NoOp-host
|
|
/// bypass. Kept separate from the shared <see cref="FakeAutomation"/> so
|
|
/// every other test here is unaffected.
|
|
/// </summary>
|
|
private sealed class CombatCapableFakeAutomation :
|
|
IAutomationSurface, ICharacterInfo, ISpellCatalog, IMagicCommands,
|
|
IPluginChat, ICombatAutomation, IEquipmentAutomation, IItemAutomation
|
|
{
|
|
public bool IsAvailable { get; set; } = true;
|
|
public ICharacterInfo Character => this;
|
|
public ISpellCatalog Spells => this;
|
|
public IMagicCommands Magic => this;
|
|
public IPluginChat Chat => this;
|
|
public ICombatAutomation Combat => this;
|
|
public IEquipmentAutomation Equipment => this;
|
|
public IItemAutomation Items => this;
|
|
|
|
/// <summary>Ordered log of every mode/equip/attack/cast call.</summary>
|
|
public List<string> CallLog { get; } = [];
|
|
|
|
// ── character ─────────────────────────────────────────────────
|
|
public bool IsInWorld => IsAvailable;
|
|
public uint ObjectId { get; set; } = 1;
|
|
public uint CurrentHealth { get; set; }
|
|
public uint MaxHealth { get; set; }
|
|
public uint CurrentStamina { get; set; }
|
|
public uint MaxStamina { get; set; }
|
|
public uint CurrentMana { get; set; }
|
|
public uint MaxMana { get; set; }
|
|
public int SummoningMastery => 0;
|
|
public IReadOnlyList<PluginSkillInfo> Skills { get; set; } = [];
|
|
public IReadOnlyList<PluginAttributeInfo> Attributes { get; set; } = [];
|
|
public IReadOnlyList<PluginActiveEnchantment> ActiveEnchantments { get; set; } = [];
|
|
public IReadOnlyList<PluginSpellInfo> KnownSelfBuffs { get; set; } = [];
|
|
public IReadOnlyList<PluginSpellInfo> KnownAttackSpells { get; set; } = [];
|
|
public IReadOnlyList<PluginSpellInfo> KnownCombatSpells { get; set; } = [];
|
|
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 KnownSelfBuffs)
|
|
{
|
|
if (candidate.SpellId == spellId)
|
|
{
|
|
info = candidate;
|
|
return true;
|
|
}
|
|
}
|
|
info = default;
|
|
return false;
|
|
}
|
|
|
|
// ── magic ─────────────────────────────────────────────────────
|
|
public bool IsCasting { get; set; }
|
|
public List<uint> CastSpellIds { get; } = [];
|
|
public PluginCastGate EvaluateGate(uint spellId) => PluginCastGate.Ready;
|
|
public bool Cast(uint spellId)
|
|
{
|
|
CastSpellIds.Add(spellId);
|
|
CallLog.Add($"Cast:{spellId}");
|
|
return true;
|
|
}
|
|
|
|
// ── chat ──────────────────────────────────────────────────────
|
|
public List<string> Messages { get; } = [];
|
|
public void PostSystemMessage(string text) => Messages.Add(text);
|
|
|
|
// ── inventory (used only to build the Items profile through the
|
|
// panel's real AddSelectedItem/SetMonsterWeapon UI flow) ────────
|
|
bool IItemAutomation.IsAvailable => true;
|
|
bool IItemAutomation.IsBusy => false;
|
|
public IReadOnlyList<PluginInventoryItem> ItemEntries { get; set; } = [];
|
|
public IReadOnlyList<PluginInventoryItem> CaptureOwnedItems() => ItemEntries;
|
|
|
|
// ── combat ────────────────────────────────────────────────────
|
|
public PluginCombatSnapshot CombatSnapshot { get; set; } = new(
|
|
SelectedObjectId: 0,
|
|
PluginCombatMode.Peace,
|
|
PluginAttackHeight.Medium,
|
|
DesiredPower: 0.5f,
|
|
PowerBarLevel: 0f,
|
|
BuildInProgress: false,
|
|
RequestInProgress: false,
|
|
ServerResponsePending: false,
|
|
RepeatAttackInProgress: false);
|
|
PluginCombatSnapshot ICombatAutomation.Snapshot => CombatSnapshot;
|
|
public IReadOnlyList<PluginCombatTarget> Targets { get; set; } = [];
|
|
public IReadOnlyList<PluginCombatTarget> CaptureHostileTargets(
|
|
float maximumDistance) => Targets;
|
|
public int ModeChangeRequests { get; private set; }
|
|
public PluginCombatCommandResult EnterMode(PluginCombatMode mode)
|
|
{
|
|
ModeChangeRequests++;
|
|
CombatSnapshot = CombatSnapshot with { Mode = mode };
|
|
CallLog.Add($"EnterMode:{mode}");
|
|
return new(PluginCombatCommandStatus.ModeChangeSent);
|
|
}
|
|
public PluginCombatCommandResult EnterDefaultMode()
|
|
{
|
|
PluginCombatMode mode = PluginCombatMode.Peace;
|
|
foreach (PluginEquipmentItem item in EquipmentItems)
|
|
{
|
|
if (!item.IsEquipped)
|
|
continue;
|
|
mode = (item.ItemType & 0x00008000u) != 0u
|
|
? PluginCombatMode.Magic
|
|
: PluginCombatMode.Melee;
|
|
break;
|
|
}
|
|
CombatSnapshot = CombatSnapshot with { Mode = mode };
|
|
CallLog.Add($"EnterDefaultMode:{mode}");
|
|
return new(PluginCombatCommandStatus.ModeChangeSent);
|
|
}
|
|
public int BeginCount { get; private set; }
|
|
public uint LastBeginTarget { get; private set; }
|
|
public PluginCombatCommandResult BeginPhysicalAttack(
|
|
uint targetObjectId, PluginAttackHeight height, float power)
|
|
{
|
|
LastBeginTarget = targetObjectId;
|
|
BeginCount++;
|
|
CallLog.Add($"Attack:{targetObjectId:X8}");
|
|
return new(PluginCombatCommandStatus.Started);
|
|
}
|
|
public PluginCombatCommandResult ReleasePhysicalAttack() =>
|
|
new(PluginCombatCommandStatus.Released);
|
|
public PluginCombatCommandResult AbortPhysicalAttack() =>
|
|
new(PluginCombatCommandStatus.Stopped);
|
|
|
|
// ── equipment ─────────────────────────────────────────────────
|
|
bool IEquipmentAutomation.IsAvailable => true;
|
|
bool IEquipmentAutomation.IsBusy => false;
|
|
public IReadOnlyList<PluginEquipmentItem> EquipmentItems { get; set; } = [];
|
|
public IReadOnlyList<PluginEquipmentItem> CaptureOwnedEquipment() =>
|
|
EquipmentItems;
|
|
public PluginEquipmentCommandResult Equip(
|
|
uint objectId,
|
|
uint requestedLocation = 0u)
|
|
{
|
|
CallLog.Add($"Equip:{objectId:X8}");
|
|
// Retail AutoWield swaps whatever else occupied the held-item
|
|
// slot; this fake has exactly one such slot in play, so wielding
|
|
// a new item unequips any other.
|
|
EquipmentItems = EquipmentItems
|
|
.Select(item => item.ObjectId == objectId
|
|
? item with { EquippedLocation = 0x00100000u }
|
|
: item with { EquippedLocation = 0u })
|
|
.ToArray();
|
|
return new(PluginEquipmentCommandStatus.Started);
|
|
}
|
|
}
|
|
|
|
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 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 + "/", 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 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 { 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;
|
|
}
|
|
}
|
|
}
|