MossTankProfileStore now stores the VTank-catalog Settings (Combat/Buffs/ Vitals/Inventory/Navigation) as a real .usd database read/written through VtankSettingsProfileSerializer, listed via VtankProfileDirectory's real naming rules (auto "By char" file, "--Name_Server_" sub-profiles, .cdf per-character binding) instead of a hashed JSON document. Only state with no VTank setting name (item/consumable lists, the monster-rule table, and a handful of MossTank-only knobs) still lives in a small JSON side-car keyed by the real .usd file name. A one-time migration converts a not-yet-migrated legacy JSON profile into its .usd + side-car pair on first load and deletes the JSON key, leaving an existing .usd counterpart (and its stale JSON) untouched. `opt setinall` now patches every known .usd file's Settings row directly, keeping each file's side-car DynamicSettings mirror in sync so MossTankPanel's existing option-override replay doesn't clobber the freshly patched value on the next load. VtankProfileDirectory gained WriteCharacterBinding (the .cdf writer counterpart to the existing reader). VtankSettingsProfileSerializer.Apply is now internal so the store can seed live settings directly from a parsed database without a text round-trip. Mutation shown to fail: MigrateLegacyIfNeeded stubbed to a no-op made FirstLoadMigratesLegacyJsonMacroProfileToUsdAndDeletesTheJsonKey fail (legacy key was not deleted); restored, it passes along with the ExistingUsdCounterpartLeavesLegacyJsonUntouchedAndUnread companion test. 585 -> 587 MossTank tests passing (net +2 after adjusting three existing tests to the new, more retail-faithful defaults/identity: a brand-new profile now seeds from VTank's own shipped defaultsettings.usd rather than a MossTank-guessed CLR default, and Selected/MacroProfileNames now surface the real VTank file name instead of a bare invented name). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1954 lines
72 KiB
C#
1954 lines
72 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);
|
|
}
|
|
|
|
[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);
|
|
}
|
|
|
|
// 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 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");
|
|
// 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["exports/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
|
|
// "exports/nav/Foo.af.af" export instead of "exports/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("exports/nav/Foo.af"));
|
|
Assert.False(storage.Text.ContainsKey("exports/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. 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.Contains(
|
|
"STATE: ",
|
|
storage.Text["exports/meta/Same.af"],
|
|
StringComparison.Ordinal);
|
|
Assert.Contains(
|
|
"NAV: ",
|
|
storage.Text["exports/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 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.Contains(
|
|
"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);
|
|
}
|
|
|
|
// 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("exports/meta/Foo.af"));
|
|
Assert.False(storage.Text.ContainsKey("exports/meta/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;
|
|
}
|
|
}
|
|
}
|