acdream/tests/AcDream.Plugins.MossTank.Tests/MossTankPanelTests.cs
Erik 8a146aa8c4 feat(vt): Profiles tab Delete action, bound through the markup contract
Campaign VT slice 1 Part A round 2 step 5: the Profiles tab's action set
(Create/Select/Save/Delete/name field/mine-only) was missing Delete
entirely — every profile family only ever had Create/Copy/Clear. Adds
MossTankProfileStore.Delete (removes the selected named profile's real
.usd file and its side-car, falls back to "By char"; refuses for "By
char" itself, which has nothing to delete — see ClearCurrent for that
case) and wires it through MossTankPanel.DeleteProfile to a new "Delete"
button in mosstank.xml, next to "Clear profile!".

Create/Select/the name field/the mine-only toggle already bind to the
directory-backed store from steps 1-4; this closes the one missing verb.

Mutation shown to fail: Delete short-circuited to always refuse made
DeleteProfileRemovesTheRealFileAndFallsBackToByCharacter fail (selection
stayed on the named file instead of falling back); restored, it passes
along with the By-char refusal companion test. The markup contract's
interactive-control count was updated for the new button (190 -> 191).

595 MossTank tests passing (was 593).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 00:34:34 +02:00

2108 lines
79 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);
}
/// <summary>
/// Reproduces the pre-cutover hashed JSON key a "By char" macro profile
/// used to be stored under (<c>MossTankProfileStore.LegacyProfileKey</c>,
/// not directly callable from a test — the hash and identity string are
/// reproduced verbatim here since the format is the migration contract
/// itself, byte for byte).
/// </summary>
private static string LegacyByCharacterProfileKey(string characterName)
{
string identity = "char:" + characterName.Trim().ToUpperInvariant();
string hash = Convert.ToHexString(
System.Security.Cryptography.SHA256.HashData(
System.Text.Encoding.UTF8.GetBytes(identity)));
return $"profiles/macro/{hash}.json";
}
[Fact]
public void FirstLoadMigratesLegacyJsonMacroProfileToUsdAndDeletesTheJsonKey()
{
var storage = new MemoryStorage();
var automation = new FakeAutomation { Name = "Barris" };
string legacyKey = LegacyByCharacterProfileKey("Barris");
storage.Text[legacyKey] = """
{
"Combat": { "MaximumRange": 48.0 },
"ItemNames": ["Wand of Testing"]
}
""";
var panel = new MossTankPanel(new FakeHost(automation, storage));
// The legacy key is gone; the real .usd file (this character's
// auto "By char" name) exists with the migrated value.
Assert.False(storage.Text.ContainsKey(legacyKey));
string usdKey = VtankProfileDirectory.AutoCharacterFileName("Barris", string.Empty, "usd");
Assert.True(storage.Text.ContainsKey(usdKey));
Assert.Equal(0.2d, panel.EvaluateExpression("uboptget['AttackDistance']").AsNumber(), precision: 7);
Assert.Contains("Wand of Testing", panel.ItemProfileText, StringComparison.Ordinal);
// Idempotent second run: a fresh panel against the same storage
// loads straight from the now-real .usd file, migrates nothing
// (there is no legacy key left to consult), and keeps the value.
var reloaded = new MossTankPanel(new FakeHost(
new FakeAutomation { Name = "Barris" }, storage));
Assert.Equal(0.2d, reloaded.EvaluateExpression("uboptget['AttackDistance']").AsNumber(), precision: 7);
Assert.Contains("Wand of Testing", reloaded.ItemProfileText, StringComparison.Ordinal);
}
[Fact]
public void ExistingUsdCounterpartLeavesLegacyJsonUntouchedAndUnread()
{
var storage = new MemoryStorage();
var automation = new FakeAutomation { Name = "Barris" };
string legacyKey = LegacyByCharacterProfileKey("Barris");
storage.Text[legacyKey] = """{ "Combat": { "MaximumRange": 240.0 } }""";
string usdKey = VtankProfileDirectory.AutoCharacterFileName("Barris", string.Empty, "usd");
var seedCombat = new CombatSettings { MaximumRange = 120d };
VtankDatabase seedDatabase = VtankSettingsProfileSerializer.CreateNew(
new VtankSettingsProfileSerializer.AllSettings
{
Combat = seedCombat,
Buffs = new BuffSettings(),
Vitals = new VitalSettings(),
Inventory = new InventorySettings(),
Navigation = new NavigationSettings(),
});
storage.Text[usdKey] = seedDatabase.Render();
var panel = new MossTankPanel(new FakeHost(automation, storage));
// The real .usd file wins; the stale legacy JSON is left completely
// alone (not read, not deleted) rather than silently discarded.
Assert.True(storage.Text.ContainsKey(legacyKey));
Assert.Equal(0.5d, panel.EvaluateExpression("uboptget['AttackDistance']").AsNumber(), precision: 7);
}
/// <summary>
/// Reproduces MossTankMetaProfileStore's pre-cutover by-character JSON
/// hash key (its own <c>Hash(string)</c> — 12-byte truncated SHA256,
/// lowercase hex — is private; the format is the migration contract
/// itself, reproduced verbatim here).
/// </summary>
private static string LegacyMetaByCharacterKey(string characterName)
{
byte[] hash = System.Security.Cryptography.SHA256.HashData(
System.Text.Encoding.UTF8.GetBytes(characterName.ToLowerInvariant()));
return $"profiles/meta/by-character/{Convert.ToHexString(hash.AsSpan(0, 12)).ToLowerInvariant()}.json";
}
[Fact]
public void MetaStoreMigratesLegacyJsonProfileToAfAndDeletesTheJsonKey()
{
var storage = new MemoryStorage();
string legacyKey = LegacyMetaByCharacterKey("Barris");
var legacyProfile = new MetaProfile
{
Rules =
[
new MetaRule
{
State = "Default",
Condition = MetaCondition.Always(),
Action = new MetaAction { Kind = MetaActionKind.ChatCommand, Text = "/say hi" },
Enabled = true,
},
],
};
storage.Text[legacyKey] = System.Text.Json.JsonSerializer.Serialize(
legacyProfile,
new System.Text.Json.JsonSerializerOptions { WriteIndented = true });
var store = new MossTankMetaProfileStore(
new FakeHost(new FakeAutomation { Name = "Barris" }, storage));
store.BindCharacter("Barris");
MetaProfile loaded = store.LoadCurrent();
Assert.False(storage.Text.ContainsKey(legacyKey));
MetaRule rule = Assert.Single(loaded.Rules);
Assert.Equal("/say hi", rule.Action.Text);
// Idempotent second run: nothing left to migrate, loads straight
// from the now-real .af file.
var reopened = new MossTankMetaProfileStore(
new FakeHost(new FakeAutomation { Name = "Barris" }, storage));
reopened.BindCharacter("Barris");
MetaProfile reloaded = reopened.LoadCurrent();
Assert.Single(reloaded.Rules);
}
[Fact]
public void MetaStoreLeavesLegacyJsonUntouchedWhenAfCounterpartExists()
{
var storage = new MemoryStorage();
string legacyKey = LegacyMetaByCharacterKey("Barris");
storage.Text[legacyKey] = System.Text.Json.JsonSerializer.Serialize(new MetaProfile
{
Rules = [new MetaRule { Action = new MetaAction { Kind = MetaActionKind.ChatCommand, Text = "/say stale" } }],
});
string realKey = VtankProfileDirectory.AutoCharacterFileName("Barris", string.Empty, "af");
storage.Text[realKey] = MetafSerializer.SaveMeta(new MetaProfile
{
Rules = [new MetaRule { Action = new MetaAction { Kind = MetaActionKind.ChatCommand, Text = "/say real" } }],
});
var store = new MossTankMetaProfileStore(
new FakeHost(new FakeAutomation { Name = "Barris" }, storage));
store.BindCharacter("Barris");
MetaProfile loaded = store.LoadCurrent();
Assert.True(storage.Text.ContainsKey(legacyKey));
Assert.Equal("/say real", Assert.Single(loaded.Rules).Action.Text);
}
[Fact]
public void MetaStoreRefusesToSaveADisabledRuleAndKeepsThePriorAfContent()
{
var storage = new MemoryStorage();
var store = new MossTankMetaProfileStore(
new FakeHost(new FakeAutomation { Name = "Barris" }, storage));
store.BindCharacter("Barris");
var enabledOnly = new MetaProfile
{
Rules = [new MetaRule { Action = new MetaAction { Kind = MetaActionKind.ChatCommand, Text = "/say good" } }],
};
Assert.True(store.SaveCurrent(enabledOnly));
string key = VtankProfileDirectory.AutoCharacterFileName("Barris", string.Empty, "af");
string goodContent = storage.Text[key];
var withDisabledRule = new MetaProfile
{
Rules =
[
new MetaRule { Action = new MetaAction { Kind = MetaActionKind.ChatCommand, Text = "/say good" } },
new MetaRule { Action = new MetaAction { Kind = MetaActionKind.ChatCommand, Text = "/say off" }, Enabled = false },
],
};
bool saved = store.SaveCurrent(withDisabledRule);
Assert.False(saved);
Assert.NotNull(store.SaveNotice);
Assert.Contains("disabled", store.SaveNotice, StringComparison.OrdinalIgnoreCase);
// The refusal must not silently drop the disabled rule: the file on
// disk keeps its last good, fully-representable content.
Assert.Equal(goodContent, storage.Text[key]);
}
[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);
// A brand-new profile now seeds from VTank's own shipped
// defaultsettings.usd (CorpseApproachRange-Max = 0), not a
// MossTank-invented 40m CLR default, so one decrement from 0 clamps
// at the 2m floor rather than landing on 38.
Assert.Equal("Corpse range 2m", 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();
// Selection now surfaces the real VTank file name (the
// "--Name_Server_" sub-profile convention), not the bare name the
// user typed — this is the whole point of the .usd cutover.
const string fellowshipFile = "--Moss Wart__Fellowship.usd";
Assert.Equal(fellowshipFile, panel.SelectedMacroProfile);
Assert.Contains(fellowshipFile, panel.MacroProfileNames);
panel.SetNormalHealth(0.88f);
panel.SelectMacroProfile(MossTankProfileStore.ByCharacter);
Assert.Equal(0.42f, panel.NormalHealthValue, precision: 2);
// A bare name typed back (matching VTank's own "/vt settings load"
// convention) still resolves to this character's own sub-profile.
panel.SelectMacroProfile("Fellowship");
Assert.Equal(fellowshipFile, panel.SelectedMacroProfile);
Assert.Equal(0.88f, panel.NormalHealthValue, precision: 2);
}
// Campaign VT slice 1 Part A round 2 step 5: the Profiles tab's new
// Delete button (mosstank.xml), bound through DeleteProfile exactly
// like every other markup action.
[Fact]
public void DeleteProfileRemovesTheRealFileAndFallsBackToByCharacter()
{
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();
const string fellowshipFile = "--Moss Wart__Fellowship.usd";
Assert.Equal(fellowshipFile, panel.SelectedMacroProfile);
Assert.Contains(fellowshipFile, panel.MacroProfileNames);
panel.DeleteProfile();
Assert.Equal(MossTankProfileStore.ByCharacter, panel.SelectedMacroProfile);
Assert.DoesNotContain(fellowshipFile, panel.MacroProfileNames);
Assert.False(storage.Text.ContainsKey(fellowshipFile));
Assert.Contains("Deleted", panel.ProfileLifecycleNotice, StringComparison.Ordinal);
}
[Fact]
public void DeleteProfileRefusesToRemoveByCharacter()
{
var storage = new MemoryStorage();
var panel = new MossTankPanel(new FakeHost(new FakeAutomation(), storage));
panel.DeleteProfile();
Assert.Equal(MossTankProfileStore.ByCharacter, panel.SelectedMacroProfile);
Assert.Contains("cannot be deleted", panel.ProfileLifecycleNotice, StringComparison.Ordinal);
}
// Item J (slice-1 fix round): renamed from
// NavCommandsImportAndExportExactVtankNavFiles — .af is a real writer
// output now (item E's header/fold-marker emission), not a byte-exact
// pass-through of the imported .nav, so the old name overstated what
// this proves.
[Fact]
public void NavCommandsImportLegacyAndExportAf()
{
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 storage/authoring format now (Campaign VT slice 1
// Part A round 2 — the old "exports/nav/" side-car mirror is gone;
// this is the real route file, named with the "nav_" prefix so it
// never collides with a Meta profile of the same user-typed name in
// the shared VtankProfiles directory).
Command(panel, "nav save Exported.nav");
// Item E (slice-1 fix round): the writer now prepends metaf's own
// navHeader block, so the file no longer STARTS with "NAV: " —
// Contains proves the body is still there.
Assert.Contains(
"NAV: ",
storage.Text["nav_Exported.af"],
StringComparison.Ordinal);
}
// Item J (slice-1 fix round): "/vt nav save Foo.af" used to keep the
// ".af" suffix (only ".nav" was stripped), producing a doubled
// "nav_Foo.af.af" export instead of "nav_Foo.af".
[Fact]
public void NavSaveAcceptsAnAfSuffixedNameWithoutDoublingIt()
{
var storage = new MemoryStorage();
var panel = new MossTankPanel(new FakeHost(new FakeAutomation(), storage));
Command(panel, "nav save Foo.af");
Assert.True(storage.Text.ContainsKey("nav_Foo.af"));
Assert.False(storage.Text.ContainsKey("nav_Foo.af.af"));
}
// 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. Campaign VT slice-1 round 2 replaced the
// separate exports/meta/ + exports/nav/ side-car mirrors with the real
// storage cutover: both stores now write directly into the shared
// VtankProfiles directory, so the same collision risk exists there
// instead — resolved by the route store's "nav_" file-name prefix
// (metaf's own observed convention for a stand-alone nav .af).
[Fact]
public void MetaAndRouteProfilesWithTheSameNameDoNotCollide()
{
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("Same.af"));
Assert.True(storage.Text.ContainsKey("nav_Same.af"));
Assert.Contains(
"STATE: ",
storage.Text["Same.af"],
StringComparison.Ordinal);
Assert.Contains(
"NAV: ",
storage.Text["nav_Same.af"],
StringComparison.Ordinal);
}
// Item J (slice-1 fix round): renamed from
// MetaCommandsImportAndExportExactVtankMetFiles for the same reason as
// the nav test above.
[Fact]
public void MetaCommandsImportLegacyAndExportAf()
{
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 storage/authoring format now (Campaign VT slice 1
// Part A round 2 — the .met writer was deleted, one-shot import
// only, and the old "exports/meta/" side-car mirror is gone: this
// IS the real profile file).
Command(panel, "meta save Exported.met");
Assert.Contains(
"STATE: ",
storage.Text["Exported.af"],
StringComparison.Ordinal);
Assert.True(MetafSerializer.TryLoadMeta(
storage.Text["Exported.af"],
NoOpSpellCatalogForExport.Instance,
out MetaProfile exported,
out string error), error);
Assert.Single(exported.Rules);
}
// Item J (slice-1 fix round): same doubled-extension bug as the nav
// side, "/vt meta save Foo.af".
[Fact]
public void MetaSaveAcceptsAnAfSuffixedNameWithoutDoublingIt()
{
var storage = new MemoryStorage();
var panel = new MossTankPanel(new FakeHost(new FakeAutomation(), storage));
Command(panel, "meta save Foo.af");
Assert.True(storage.Text.ContainsKey("Foo.af"));
Assert.False(storage.Text.ContainsKey("Foo.af.af"));
}
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,
IPluginStorage? vtankProfiles = 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;
// Real VTank .usd/.af/.cdf storage. Defaults to the SAME backing
// store as the plugin's own JSON storage when a test supplies one
// (the key namespaces never collide: JSON keys are always "/"-
// prefixed, VTank file names never are) so existing tests keep
// exercising real persistence without every call site needing a
// second storage instance.
public IPluginStorage VtankProfiles { get; } =
vtankProfiles ?? storage ?? NoOpPluginStorage.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 => prefix.Length == 0
|| 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;
}
}
}