Owner live report 2026-09-07: "Some Hex numbers with green button to
the right. What is that?" — the Advanced Options popup's category
filter checklist showed raw bitmask hex ("0x1", "0x2", ...) instead of
VTank's real category names (Misc, Recharge, MeleeCombat, SpellCombat,
Ranges, Navigation, Buffing, Crafting, Looting).
This worktree has no refs/vtank/ checkout, so VTank's own name/enum
string literal for each bit isn't directly readable. Derived the
mapping instead from real data already in the embedded .usd: for each
category name, docs/research/vtank-kb/01-settings-and-profiles.md §2
was searched for a setting whose Category column names EXACTLY that
one category (no `|` combination) — e.g. row 45 RandomHelperBuffs is
pure "Misc", row 18 AttackDistance is pure "Ranges" — then that
setting's own recorded bitmask was read back from
VtankDefaultSettingsDatabase.SettingCategoryBitmasks (never a typed-in
hex literal), so the mapping tracks the shipped database instead of
silently drifting from it. All 9 of VtankOptionCatalog.CategoryBits
resolve to a real name this way (VtankOptionCatalog.
CategoryNamesByBit), in VTank's own ascending-bit order.
Mutation shown to fail: reverting AdvancedOptionCategoryNames to the
old `$"0x{bit:X}"` projection failed the new
AdvancedOptionCategoryNamesShowRealNamesNotHexBitmasks test with
["0x1", "0x2", ...] instead of ["Misc", "Recharge", ...]; restored and
confirmed green.
Verified: dotnet build AcDream.slnx -c Release green; MossTank suite
679/679 (678 -> 679); App markup/plugin filter 242/242 (unchanged).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
3634 lines
147 KiB
C#
3634 lines
147 KiB
C#
using System.Reflection;
|
|
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>
|
|
/// Round 3 item 12: a side-car monster rule whose Expression fails to
|
|
/// compile used to be silently dropped (a bare
|
|
/// <c>catch (FormatException) { }</c>); it must now log a warning
|
|
/// instead, while still falling back gracefully (the malformed rule is
|
|
/// skipped, everything else loads).
|
|
/// </summary>
|
|
[Fact]
|
|
public void CorruptSideCarMonsterRuleIsLoggedNotSilentlySwallowed()
|
|
{
|
|
var storage = new MemoryStorage();
|
|
string usdKey = VtankProfileDirectory.AutoCharacterFileName("Barris", string.Empty, "usd");
|
|
storage.Text[usdKey] = VtankDefaultSettingsDatabase.Parse().Render();
|
|
storage.Text["profiles/macro/sidecar/--Barris_.usd.json"] = """
|
|
{
|
|
"CombatRules": [
|
|
{ "Expression": "(((" },
|
|
{ "Expression": "DEFAULT" }
|
|
]
|
|
}
|
|
""";
|
|
|
|
var host = new FakeHost(new FakeAutomation { Name = "Barris" }, storage);
|
|
_ = new MossTankPanel(host);
|
|
|
|
Assert.Contains(host.Logger.Warnings, message =>
|
|
message.Contains("monster rule", StringComparison.OrdinalIgnoreCase));
|
|
}
|
|
|
|
/// <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>
|
|
/// Round 3 item 1 (BLOCKER): a drop-in <c>.usd</c> with
|
|
/// <c>EnableLooting=True</c> and NO side-car at all must load with
|
|
/// looting enabled. Before the fix, <c>SideCarDocument.Apply</c> ran
|
|
/// AFTER the <c>.usd</c> and unconditionally wrote its own
|
|
/// <c>InventoryEnableLooting</c> field (defaulting to <see langword="false"/>
|
|
/// for a profile that has never had a side-car written) over
|
|
/// <c>settings.Inventory.Loot.Enabled</c>, silently turning looting back
|
|
/// off.
|
|
/// </summary>
|
|
[Fact]
|
|
public void DropInUsdWithLootingEnabledAndNoSideCarLoadsLootingEnabled()
|
|
{
|
|
var storage = new MemoryStorage();
|
|
var automation = new FakeAutomation { Name = "Barris" };
|
|
string usdKey = VtankProfileDirectory.AutoCharacterFileName("Barris", string.Empty, "usd");
|
|
|
|
VtankDatabase database = VtankDefaultSettingsDatabase.Parse();
|
|
VtankTable settingsTable = database.Find("Settings")!;
|
|
int nameColumn = settingsTable.ColumnIndex("Setting");
|
|
int valueColumn = settingsTable.ColumnIndex("Value");
|
|
VtankRow row = settingsTable.Rows.First(candidate =>
|
|
candidate.Cells[nameColumn].AsString().Equals(
|
|
"EnableLooting", StringComparison.OrdinalIgnoreCase));
|
|
row.Cells[valueColumn] = VtankCell.Bool(true);
|
|
storage.Text[usdKey] = database.Render();
|
|
|
|
// No side-car key at all — a real drop-in, not something MossTank
|
|
// itself ever saved.
|
|
var panel = new MossTankPanel(new FakeHost(automation, storage));
|
|
|
|
Assert.True(panel.LootEnabled);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Round 3 item 2: the pre-cutover "profiles/index.json" roster carried
|
|
/// EVERY named profile a character had (<c>Profiles: [{Name, Owner}]</c>),
|
|
/// not just whichever one happened to be selected. Before the fix, the
|
|
/// settings cutover only ever converted the currently-selected profile
|
|
/// (via <c>MigrateLegacyIfNeeded</c>) — every OTHER named profile in the
|
|
/// roster was silently orphaned: never converted, never listed, and (once
|
|
/// <c>SetMineOnly</c> saved) permanently unreachable once the shared key
|
|
/// got flattened to a bare <c>{MineOnly}</c> document.
|
|
/// </summary>
|
|
private static string LegacyNamedProfileKey(string name)
|
|
{
|
|
string identity = "named:" + name.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 SettingsRosterSweepConvertsEveryNamedLegacyProfileOnce()
|
|
{
|
|
var storage = new MemoryStorage();
|
|
var automation = new FakeAutomation { Name = "Barris" };
|
|
storage.Text["profiles/index.json"] = """
|
|
{
|
|
"Version": 1,
|
|
"MineOnly": false,
|
|
"Profiles": [
|
|
{ "Name": "Farming", "Owner": "Barris" },
|
|
{ "Name": "Buffing", "Owner": "Barris" }
|
|
],
|
|
"SelectedByCharacter": {}
|
|
}
|
|
""";
|
|
storage.Text[LegacyNamedProfileKey("Farming")] =
|
|
"""{ "Combat": { "MaximumRange": 42.0 } }""";
|
|
storage.Text[LegacyNamedProfileKey("Buffing")] =
|
|
"""{ "Combat": { "MaximumRange": 24.0 } }""";
|
|
|
|
var panel = new MossTankPanel(new FakeHost(automation, storage));
|
|
|
|
// MineOnly recovered from the old roster's own shape...
|
|
Assert.False(panel.MineOnlyEnabled);
|
|
// ...and both named profiles converted to real per-character
|
|
// sub-profile .usd files, each keeping its own distinct value.
|
|
string farmingUsd = VtankProfileDirectory.SubProfilePrefix("Barris", string.Empty)
|
|
+ "Farming.usd";
|
|
string buffingUsd = VtankProfileDirectory.SubProfilePrefix("Barris", string.Empty)
|
|
+ "Buffing.usd";
|
|
Assert.Equal(42d, RangeOf(storage, farmingUsd));
|
|
Assert.Equal(24d, RangeOf(storage, buffingUsd));
|
|
|
|
// Both legacy JSON keys and the whole (now fully-swept) roster key
|
|
// are gone; MineOnly now lives at its own dedicated key.
|
|
Assert.False(storage.Text.ContainsKey(LegacyNamedProfileKey("Farming")));
|
|
Assert.False(storage.Text.ContainsKey(LegacyNamedProfileKey("Buffing")));
|
|
Assert.False(storage.Text.ContainsKey("profiles/index.json"));
|
|
Assert.True(storage.Text.ContainsKey("profiles/macro/preferences.json"));
|
|
|
|
// Idempotent: a fresh panel against the same storage sweeps nothing
|
|
// more (there is no roster key left to read) and keeps both values.
|
|
var reloaded = new MossTankPanel(new FakeHost(
|
|
new FakeAutomation { Name = "Barris" }, storage));
|
|
Assert.False(reloaded.MineOnlyEnabled);
|
|
Assert.Equal(42d, RangeOf(storage, farmingUsd));
|
|
Assert.Equal(24d, RangeOf(storage, buffingUsd));
|
|
}
|
|
|
|
private static double RangeOf(MemoryStorage storage, string usdKey)
|
|
{
|
|
string text = Assert.Contains(usdKey, (IDictionary<string, string>)storage.Text);
|
|
var settings = new VtankSettingsProfileSerializer.AllSettings
|
|
{
|
|
Combat = new CombatSettings(),
|
|
Buffs = new BuffSettings(),
|
|
Vitals = new VitalSettings(),
|
|
Inventory = new InventorySettings(),
|
|
Navigation = new NavigationSettings(),
|
|
};
|
|
VtankSettingsProfileSerializer.Load(text, settings);
|
|
return settings.Combat.MaximumRange;
|
|
}
|
|
|
|
/// <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 = "metas/" + 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);
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// Slice 1c step 2: one-time flat-file -> metas/ folder migration.
|
|
// ------------------------------------------------------------------
|
|
|
|
/// <summary>
|
|
/// Owner decision 2026-09-07: an existing flat root-level
|
|
/// <c>Name.af</c> (the pre-slice-1c naming) moves to
|
|
/// <c>metas/Name.af</c> on first load — no marker to strip, since Meta
|
|
/// files never carried one.
|
|
/// </summary>
|
|
[Fact]
|
|
public void MetaStoreMigratesFlatAfFileIntoMetasFolder()
|
|
{
|
|
var storage = new MemoryStorage();
|
|
storage.Text["Shared.af"] = MetafSerializer.SaveMeta(new MetaProfile
|
|
{
|
|
Rules = [new MetaRule { Action = new MetaAction { Kind = MetaActionKind.ChatCommand, Text = "/say shared" } }],
|
|
});
|
|
|
|
var store = new MossTankMetaProfileStore(
|
|
new FakeHost(new FakeAutomation { Name = "Barris" }, storage));
|
|
store.BindCharacter("Barris");
|
|
store.LoadCurrent();
|
|
|
|
Assert.True(storage.Text.ContainsKey("metas/Shared.af"));
|
|
Assert.False(storage.Text.ContainsKey("Shared.af"));
|
|
|
|
// Idempotent second run: nothing left at the root to migrate.
|
|
var reopened = new MossTankMetaProfileStore(
|
|
new FakeHost(new FakeAutomation { Name = "Barris" }, storage));
|
|
reopened.BindCharacter("Barris");
|
|
reopened.LoadCurrent();
|
|
Assert.True(storage.Text.ContainsKey("metas/Shared.af"));
|
|
Assert.False(storage.Text.ContainsKey("Shared.af"));
|
|
}
|
|
|
|
/// <summary>
|
|
/// A flat <c>nav_*.af</c>/<c>--nav_*.af</c> file belongs to
|
|
/// <see cref="MossTankRouteProfileStore"/>'s own sweep, not this one —
|
|
/// it must be left alone by the Meta store.
|
|
/// </summary>
|
|
[Fact]
|
|
public void MetaStoreLeavesFlatNavMarkedFilesForTheRouteStore()
|
|
{
|
|
var storage = new MemoryStorage();
|
|
storage.Text["nav_Hunt.af"] = "1\r\n";
|
|
storage.Text["--nav_Barris_Coldeve.af"] = "1\r\n";
|
|
|
|
var store = new MossTankMetaProfileStore(
|
|
new FakeHost(new FakeAutomation { Name = "Barris" }, storage));
|
|
store.BindCharacter("Barris");
|
|
store.LoadCurrent();
|
|
|
|
Assert.True(storage.Text.ContainsKey("nav_Hunt.af"));
|
|
Assert.True(storage.Text.ContainsKey("--nav_Barris_Coldeve.af"));
|
|
Assert.False(storage.Text.ContainsKey("metas/nav_Hunt.af"));
|
|
Assert.False(storage.Text.ContainsKey("metas/--nav_Barris_Coldeve.af"));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Owner decision 2026-09-07, collision rule: when the real destination
|
|
/// already exists, the leftover flat file is left in place untouched
|
|
/// (never overwritten) and the collision is logged.
|
|
/// </summary>
|
|
[Fact]
|
|
public void MetaStoreLeavesFlatFileInPlaceWhenMetasDestinationAlreadyExists()
|
|
{
|
|
var storage = new MemoryStorage();
|
|
string canonicalContent = MetafSerializer.SaveMeta(new MetaProfile
|
|
{
|
|
Rules = [new MetaRule { Action = new MetaAction { Kind = MetaActionKind.ChatCommand, Text = "/say canonical" } }],
|
|
});
|
|
storage.Text["metas/Shared.af"] = canonicalContent;
|
|
storage.Text["Shared.af"] = MetafSerializer.SaveMeta(new MetaProfile
|
|
{
|
|
Rules = [new MetaRule { Action = new MetaAction { Kind = MetaActionKind.ChatCommand, Text = "/say stale-flat" } }],
|
|
});
|
|
|
|
var host = new FakeHost(new FakeAutomation { Name = "Barris" }, storage);
|
|
var store = new MossTankMetaProfileStore(host);
|
|
store.BindCharacter("Barris");
|
|
store.LoadCurrent();
|
|
|
|
Assert.Equal(canonicalContent, storage.Text["metas/Shared.af"]);
|
|
Assert.True(storage.Text.ContainsKey("Shared.af"));
|
|
Assert.Contains(
|
|
host.Logger.Warnings,
|
|
message => message.Contains("Shared.af", StringComparison.Ordinal)
|
|
&& message.Contains("metas/Shared.af", StringComparison.Ordinal));
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// Slice 1c step 3: content sanity on load.
|
|
// ------------------------------------------------------------------
|
|
|
|
/// <summary>
|
|
/// Owner decision 2026-09-07: a real nav-only <c>.af</c> file (a route
|
|
/// profile) that ends up at <c>metas/</c> — e.g. dropped there by hand,
|
|
/// or a bug elsewhere in the pipeline — must NOT silently load as an
|
|
/// empty Meta profile. <c>LoadCurrent</c> must refuse it and leave a
|
|
/// notice naming the <c>navs/</c> folder it actually belongs in.
|
|
/// </summary>
|
|
[Fact]
|
|
public void MetaStoreRefusesToLoadANavOnlyFileWithNoticeNamingNavsFolder()
|
|
{
|
|
string navOnlyContent = File.ReadAllText(
|
|
Path.Combine(AppContext.BaseDirectory, "Fixtures", "vtank", "af", "nav_ab.af"));
|
|
var storage = new MemoryStorage();
|
|
storage.Text["metas/Misplaced.af"] = navOnlyContent;
|
|
|
|
var host = new FakeHost(new FakeAutomation { Name = "Barris" }, storage);
|
|
var store = new MossTankMetaProfileStore(host);
|
|
store.BindCharacter("Barris");
|
|
Assert.True(store.Select("Misplaced"));
|
|
|
|
MetaProfile loaded = store.LoadCurrent();
|
|
|
|
Assert.Empty(loaded.Rules);
|
|
Assert.NotNull(store.RecoveryNotice);
|
|
Assert.Contains("navs/", store.RecoveryNotice, StringComparison.Ordinal);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Reproduces MossTankMetaProfileStore's pre-cutover named-profile JSON
|
|
/// hash key (its own <c>LegacyNamedKey</c>/<c>Hash(string)</c> are
|
|
/// private; the format is the migration contract itself, reproduced
|
|
/// verbatim here).
|
|
/// </summary>
|
|
private static string LegacyMetaNamedKey(string name)
|
|
{
|
|
byte[] hash = System.Security.Cryptography.SHA256.HashData(
|
|
System.Text.Encoding.UTF8.GetBytes(name.ToLowerInvariant()));
|
|
return $"profiles/meta/named/{Convert.ToHexString(hash.AsSpan(0, 12)).ToLowerInvariant()}.json";
|
|
}
|
|
|
|
/// <summary>
|
|
/// Round 3 item 2: like the settings roster, MossTankMetaProfileStore's
|
|
/// pre-cutover "profiles/meta/index.json" carried EVERY named Meta
|
|
/// profile's name (never owner-scoped — one shared, globally-hashed key
|
|
/// per name), and the cutover stopped reading it entirely — orphaning
|
|
/// every named profile except whichever one happened to be selected.
|
|
/// </summary>
|
|
[Fact]
|
|
public void MetaRosterSweepConvertsEveryNamedLegacyProfileOnce()
|
|
{
|
|
var storage = new MemoryStorage();
|
|
storage.Text["profiles/meta/index.json"] = """{ "Names": ["Farming", "Buffing"] }""";
|
|
storage.Text[LegacyMetaNamedKey("Farming")] = System.Text.Json.JsonSerializer.Serialize(
|
|
new MetaProfile
|
|
{
|
|
Rules = [new MetaRule { Action = new MetaAction { Kind = MetaActionKind.ChatCommand, Text = "/say farming" } }],
|
|
});
|
|
storage.Text[LegacyMetaNamedKey("Buffing")] = System.Text.Json.JsonSerializer.Serialize(
|
|
new MetaProfile
|
|
{
|
|
Rules = [new MetaRule { Action = new MetaAction { Kind = MetaActionKind.ChatCommand, Text = "/say buffing" } }],
|
|
});
|
|
|
|
var store = new MossTankMetaProfileStore(
|
|
new FakeHost(new FakeAutomation { Name = "Barris" }, storage));
|
|
store.BindCharacter("Barris");
|
|
store.LoadCurrent();
|
|
|
|
Assert.False(storage.Text.ContainsKey(LegacyMetaNamedKey("Farming")));
|
|
Assert.False(storage.Text.ContainsKey(LegacyMetaNamedKey("Buffing")));
|
|
Assert.False(storage.Text.ContainsKey("profiles/meta/index.json"));
|
|
Assert.True(MetafSerializer.TryLoadMeta(
|
|
storage.Text["metas/Farming.af"], NoOpSpellCatalogForExport.Instance, out MetaProfile farming, out _));
|
|
Assert.Equal("/say farming", Assert.Single(farming.Rules).Action.Text);
|
|
Assert.True(MetafSerializer.TryLoadMeta(
|
|
storage.Text["metas/Buffing.af"], NoOpSpellCatalogForExport.Instance, out MetaProfile buffing, out _));
|
|
Assert.Equal("/say buffing", Assert.Single(buffing.Rules).Action.Text);
|
|
|
|
// Idempotent: a fresh store against the same storage sweeps nothing
|
|
// more (there is no roster key left to read).
|
|
var reopened = new MossTankMetaProfileStore(
|
|
new FakeHost(new FakeAutomation { Name = "Barris" }, storage));
|
|
reopened.BindCharacter("Barris");
|
|
reopened.LoadCurrent();
|
|
Assert.True(storage.Text.ContainsKey("metas/Farming.af"));
|
|
Assert.True(storage.Text.ContainsKey("metas/Buffing.af"));
|
|
}
|
|
|
|
[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 = "metas/" + 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.AddSelectedItem(); // Items profile: the axe
|
|
// Ordinal-sorted roster is ["Battle Axe", "War Wand"], so one cycle
|
|
// from AUTO lands on the axe — DEFAULT rule's weapon: the axe.
|
|
panel.CycleMonsterWeaponAt(0);
|
|
// (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 ItemsGridNameClickDeletesAndHandsClickCyclesHandedness()
|
|
{
|
|
// Campaign VT S7.4: VTank's own 2-column Items grid
|
|
// (PluginCore.cs:8529-8562) — col 0 (name) click deletes the row,
|
|
// col 1 (hands) click cycles handedness.
|
|
var automation = new FakeAutomation
|
|
{
|
|
ItemEntries =
|
|
[
|
|
Item(10, "Fire Sword", 1),
|
|
Item(11, "Ice Wand", 1),
|
|
],
|
|
};
|
|
var host = new FakeHost(automation);
|
|
var panel = new MossTankPanel(host);
|
|
host.Selection.Select(10);
|
|
panel.AddSelectedItem();
|
|
host.Selection.Select(11);
|
|
panel.AddSelectedItem();
|
|
Assert.Equal(["Fire Sword", "Ice Wand"], panel.ItemNameColumn);
|
|
|
|
Assert.Equal("Auto", panel.ItemHandsColumn[0]);
|
|
panel.CycleItemHandsAt(0);
|
|
Assert.Equal("1-Handed", panel.ItemHandsColumn[0]);
|
|
panel.CycleItemHandsAt(0);
|
|
Assert.Equal("2-Handed", panel.ItemHandsColumn[0]);
|
|
panel.CycleItemHandsAt(0);
|
|
Assert.Equal("Auto", panel.ItemHandsColumn[0]);
|
|
// Cycling row 0 never touches row 1's own state.
|
|
Assert.Equal("Auto", panel.ItemHandsColumn[1]);
|
|
|
|
panel.DeleteItemRowAt(0); // "Fire Sword"
|
|
Assert.Equal(["Ice Wand"], panel.ItemNameColumn);
|
|
}
|
|
|
|
[Fact]
|
|
public void ItemHandsColumnDoesNotReallocateOnEveryReadAndNoBuffSuffixNeverLeaks()
|
|
{
|
|
// Fix round C item F7: ItemHandsColumn used to run a fresh
|
|
// .Select(...).ToArray() over _itemRows PLUS a per-row BaseItemName
|
|
// suffix-strip on every single retained-UI draw. It is now
|
|
// materialized once in RefreshItemEditors: repeated reads with no
|
|
// mutation in between must return the SAME array instance.
|
|
// Fix round C item F12: the handedness lookup now uses the raw
|
|
// base-name array captured alongside the decorated display row
|
|
// (ONE place the " [no buffs]" suffix is added, in
|
|
// RefreshItemEditors) instead of parsing the suffix back off — an
|
|
// item added with noBuffs must still resolve its own handedness
|
|
// correctly, proving the fold didn't break the no-buffs case.
|
|
var automation = new FakeAutomation
|
|
{
|
|
ItemEntries = [Item(10, "Fire Sword", 1)],
|
|
};
|
|
var host = new FakeHost(automation);
|
|
var panel = new MossTankPanel(host);
|
|
host.Selection.Select(10);
|
|
panel.AddSelectedItemNoBuffs();
|
|
Assert.Equal(["Fire Sword [no buffs]"], panel.ItemNameColumn);
|
|
|
|
Assert.Same(panel.ItemHandsColumn, panel.ItemHandsColumn);
|
|
Assert.Equal("Auto", panel.ItemHandsColumn[0]);
|
|
|
|
panel.CycleItemHandsAt(0);
|
|
IReadOnlyList<string> afterCycle = panel.ItemHandsColumn;
|
|
Assert.Equal("1-Handed", afterCycle[0]);
|
|
Assert.Same(afterCycle, panel.ItemHandsColumn);
|
|
}
|
|
|
|
[Fact]
|
|
public void ConsumablesLeftListRowClickRemovesTheRowDirectly()
|
|
{
|
|
// Fix round B item 7: VTank's own Consumables left list removes the
|
|
// clicked row directly (PluginCore.cs:7683-7700) — no separate
|
|
// select-then-press-Remove step, unlike the earlier
|
|
// ItemsAndConsumablesTabsAddTheSelectedOwnedItemToRealProfiles test
|
|
// which still exercises the "Remove" button path.
|
|
var automation = new FakeAutomation
|
|
{
|
|
ItemEntries =
|
|
[
|
|
Item(20, "Iron Phial of Imperil", 0x100),
|
|
Item(21, "Black Marrow Pea", 0x20),
|
|
],
|
|
};
|
|
var host = new FakeHost(automation);
|
|
var panel = new MossTankPanel(host);
|
|
host.Selection.Select(20);
|
|
panel.AddSelectedConsumable();
|
|
host.Selection.Select(21);
|
|
panel.AddSelectedConsumable();
|
|
Assert.Equal(2, panel.ConsumableRows.Count);
|
|
|
|
panel.SelectConsumableRow(0);
|
|
|
|
Assert.Single(panel.ConsumableRows);
|
|
}
|
|
|
|
[Fact]
|
|
public void ExcludedComponentsGridAddsBySelectionAndDeletesByAnyCellClick()
|
|
{
|
|
// Campaign VT S7.4: VTank's own Consumables-tab right list
|
|
// (PluginCore.cs:7683-7776) — "Add Selected" requires a selected
|
|
// owned item, any cell click on a row deletes it.
|
|
var automation = new FakeAutomation
|
|
{
|
|
ItemEntries =
|
|
[
|
|
Item(20, "Charged Yellow Scarab", 0x20)
|
|
with { IconId = 0x06001234u },
|
|
],
|
|
};
|
|
var host = new FakeHost(automation);
|
|
var panel = new MossTankPanel(host);
|
|
|
|
Assert.Empty(panel.ExcludedComponentRows);
|
|
host.Selection.Select(20);
|
|
panel.AddSelectedComponent();
|
|
Assert.Equal(["Charged Yellow Scarab"], panel.ExcludedComponentRows);
|
|
Assert.Equal(0x06001234u, panel.ExcludedComponentIcons[0]);
|
|
|
|
// Adding the same component again is refused, not duplicated.
|
|
panel.AddSelectedComponent();
|
|
Assert.Equal(["Charged Yellow Scarab"], panel.ExcludedComponentRows);
|
|
|
|
panel.DeleteExcludedComponentAt(0);
|
|
Assert.Empty(panel.ExcludedComponentRows);
|
|
}
|
|
|
|
[Fact]
|
|
public void ExcludedComponentIconsDoesNotScanLiveInventoryOnEveryRead()
|
|
{
|
|
// Fix round B item 12: ExcludedComponentIcons used to call
|
|
// ResolveComponentIcon (CaptureOwnedItems(), a live inventory scan)
|
|
// once per row on every retained-UI draw. It must now be a plain
|
|
// field materialized once in RefreshItemEditors.
|
|
var automation = new FakeAutomation
|
|
{
|
|
ItemEntries =
|
|
[
|
|
Item(20, "Charged Yellow Scarab", 0x20) with { IconId = 0x06001234u },
|
|
],
|
|
};
|
|
var host = new FakeHost(automation);
|
|
var panel = new MossTankPanel(host);
|
|
host.Selection.Select(20);
|
|
panel.AddSelectedComponent();
|
|
Assert.Equal(0x06001234u, panel.ExcludedComponentIcons[0]);
|
|
|
|
int callsAfterAdd = automation.CaptureOwnedItemsCallCount;
|
|
// Simulate several retained-UI draw frames reading the same column.
|
|
for (int i = 0; i < 5; i++)
|
|
_ = panel.ExcludedComponentIcons;
|
|
|
|
Assert.Equal(callsAfterAdd, automation.CaptureOwnedItemsCallCount);
|
|
}
|
|
|
|
[Fact]
|
|
public void ExtraBuffAndBlacklistedFamilyNamesPersistAcrossSessions()
|
|
{
|
|
// Fix round B item 14: these two Buffs-tab sets were never captured
|
|
// by LegacyBuffProfileDocument, so a restart or profile switch
|
|
// silently dropped them — a real display/storage honesty bug
|
|
// (BuffPlan.Build still doesn't READ either set for casting, a
|
|
// separately tracked wiring gap; this only fixes storage).
|
|
var storage = new MemoryStorage();
|
|
var automation = new FakeAutomation
|
|
{
|
|
Name = "Persist Check",
|
|
KnownSelfBuffs =
|
|
[
|
|
Spell(1, 10, "Increases the caster's Strength by 10 points."),
|
|
Spell(2, 20, "Increases the caster's Focus by 10 points."),
|
|
],
|
|
};
|
|
var first = new MossTankPanel(new FakeHost(automation, storage));
|
|
first.ShowExtraBuffPicker();
|
|
first.PickBuffAt(0); // "Spell 1"
|
|
first.ShowBlacklistedBuffPicker();
|
|
first.PickBuffAt(1); // "Spell 2"
|
|
Assert.Equal(["Spell 1"], first.ExtraBuffRows);
|
|
Assert.Equal(["Spell 2"], first.BlacklistedBuffFamilyRows);
|
|
|
|
var second = new MossTankPanel(new FakeHost(
|
|
new FakeAutomation { Name = "Persist Check" }, storage));
|
|
|
|
Assert.Equal(["Spell 1"], second.ExtraBuffRows);
|
|
Assert.Equal(["Spell 2"], second.BlacklistedBuffFamilyRows);
|
|
}
|
|
|
|
[Fact]
|
|
public void BuffPickerAddsToTheRequestedListAndAnyCellClickDeletes()
|
|
{
|
|
// Campaign VT S7.4: the shared SelfBuffChoiceView-style picker adds
|
|
// to whichever list opened it; each list's own click-anywhere-
|
|
// deletes matches PluginCore.cs:7323-7355.
|
|
var automation = new FakeAutomation
|
|
{
|
|
KnownSelfBuffs =
|
|
[
|
|
Spell(1, 10, "Increases the caster's Strength by 10 points."),
|
|
Spell(2, 20, "Increases the caster's Focus by 10 points."),
|
|
],
|
|
};
|
|
var panel = new MossTankPanel(new FakeHost(automation));
|
|
|
|
Assert.False(panel.BuffPickerVisible);
|
|
panel.ShowExtraBuffPicker();
|
|
Assert.True(panel.BuffPickerVisible);
|
|
Assert.Equal(2, panel.BuffPickerRows.Count);
|
|
|
|
panel.SetBuffPickerSearchText("Spell 1");
|
|
Assert.Equal(["Spell 1"], panel.BuffPickerRows);
|
|
panel.PickBuffAt(0);
|
|
Assert.False(panel.BuffPickerVisible);
|
|
Assert.Equal(["Spell 1"], panel.ExtraBuffRows);
|
|
Assert.Empty(panel.BlacklistedBuffFamilyRows);
|
|
|
|
panel.ShowBlacklistedBuffPicker();
|
|
panel.SetBuffPickerSearchText(string.Empty);
|
|
panel.PickBuffAt(1); // "Spell 2" (sorted after "Spell 1")
|
|
Assert.Equal(["Spell 2"], panel.BlacklistedBuffFamilyRows);
|
|
|
|
panel.DeleteExtraBuffAt(0);
|
|
Assert.Empty(panel.ExtraBuffRows);
|
|
panel.DeleteBlacklistedBuffFamilyAt(0);
|
|
Assert.Empty(panel.BlacklistedBuffFamilyRows);
|
|
}
|
|
|
|
[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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Campaign VT slice 7 S7.2: VTank's own nine Vitals sliders are
|
|
/// minimum="0" maximum="100" and read the percent directly
|
|
/// (docs/research/vtank-kb/08-ui-views.md §1); the *Percent wrapper
|
|
/// pair must read/write the exact same underlying field as the
|
|
/// existing 0.0-1.0 *Value/Set* pair, just rescaled — never
|
|
/// independent state that could drift out of sync.
|
|
/// </summary>
|
|
[Fact]
|
|
public void VitalsPercentWrappersReadAndWriteTheSameFieldAsTheZeroToOnePair()
|
|
{
|
|
var panel = new MossTankPanel(new FakeHost(new FakeAutomation(), new MemoryStorage()));
|
|
|
|
panel.SetNormalHealth(0.42f);
|
|
Assert.Equal(42f, panel.NormalHealthPercent, precision: 2);
|
|
|
|
panel.SetHelperManaPercent(65f);
|
|
Assert.Equal(0.65f, panel.HelperManaValue, precision: 3);
|
|
Assert.Equal(65f, panel.HelperManaPercent, precision: 2);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Campaign VT slice 7 S7.2: VTank's Options tab authors cFastCast,
|
|
/// cCollisionChecks ("Don't Shoot at Walls", bound to
|
|
/// UseProjectileAwareness), and cDebuffFallback as direct checkboxes
|
|
/// (docs/research/vtank-kb/08-ui-views.md §1). The three settings
|
|
/// already existed (reachable only through the generic Advanced
|
|
/// Options key-value editor); this pins the new direct bool/Action
|
|
/// pair reading and writing the SAME backing fields, and that the
|
|
/// change persists with the profile.
|
|
/// </summary>
|
|
[Fact]
|
|
public void FastCastProjectileAwarenessAndDebuffFallbackToggleTheirVtankDefaultsAndPersist()
|
|
{
|
|
var storage = new MemoryStorage();
|
|
var first = new MossTankPanel(new FakeHost(new FakeAutomation(), storage));
|
|
|
|
Assert.False(first.FastCastBuffsEnabled);
|
|
Assert.True(first.DontShootAtWallsEnabled);
|
|
Assert.False(first.DebuffFallbackEnabled);
|
|
|
|
first.ToggleFastCastBuffs();
|
|
first.ToggleDontShootAtWalls();
|
|
first.ToggleDebuffFallback();
|
|
|
|
Assert.True(first.FastCastBuffsEnabled);
|
|
Assert.False(first.DontShootAtWallsEnabled);
|
|
Assert.True(first.DebuffFallbackEnabled);
|
|
|
|
var second = new MossTankPanel(new FakeHost(new FakeAutomation(), storage));
|
|
Assert.True(second.FastCastBuffsEnabled);
|
|
Assert.False(second.DontShootAtWallsEnabled);
|
|
Assert.True(second.DebuffFallbackEnabled);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Campaign VT slice 7 S7.1: VTank's cShowAdvanced (Options) and
|
|
/// cShowLootEditor (Profiles) are single Checkboxes that both open AND
|
|
/// close their popup depending on checked state — our own Show/Hide
|
|
/// pair had no combined toggle a markup <toggle checked=...> could
|
|
/// bind to.
|
|
/// </summary>
|
|
[Fact]
|
|
public void ToggleAdvancedOptionsAndLootEditorVisibilityFlipBothWays()
|
|
{
|
|
var panel = new MossTankPanel(new FakeHost(new FakeAutomation(), new MemoryStorage()));
|
|
|
|
Assert.False(panel.AdvancedOptionsVisible);
|
|
panel.ToggleAdvancedOptionsVisible();
|
|
Assert.True(panel.AdvancedOptionsVisible);
|
|
panel.ToggleAdvancedOptionsVisible();
|
|
Assert.False(panel.AdvancedOptionsVisible);
|
|
|
|
Assert.False(panel.LootEditorVisible);
|
|
panel.ToggleLootEditorVisible();
|
|
Assert.True(panel.LootEditorVisible);
|
|
panel.ToggleLootEditorVisible();
|
|
Assert.False(panel.LootEditorVisible);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Round D item 1 (owner live report 2026-09-07: "Some Hex numbers with
|
|
/// green button to the right. What is that?"): the category filter
|
|
/// checklist must show VTank's real category NAMES, never the raw
|
|
/// bitmask hex the popup showed before this fix. Order is
|
|
/// VtankOptionCatalog.CategoryBits' own ascending-bit order, which is
|
|
/// also VTank's cmbAdvOptFilterCategory order (Misc=1 through
|
|
/// Looting=256).
|
|
/// </summary>
|
|
[Fact]
|
|
public void AdvancedOptionCategoryNamesShowRealNamesNotHexBitmasks()
|
|
{
|
|
var panel = new MossTankPanel(new FakeHost(new FakeAutomation()));
|
|
|
|
Assert.Equal(
|
|
[
|
|
"Misc", "Recharge", "MeleeCombat", "SpellCombat", "Ranges",
|
|
"Navigation", "Buffing", "Crafting", "Looting",
|
|
],
|
|
panel.AdvancedOptionCategoryNames);
|
|
Assert.DoesNotContain(
|
|
panel.AdvancedOptionCategoryNames, name => name.StartsWith("0x", StringComparison.Ordinal));
|
|
}
|
|
|
|
[Fact]
|
|
public void AdvancedOptionCategoryFilterHidesNonMatchingSettings()
|
|
{
|
|
// Fix round B item 9: lFilterList (docs/research/vtank-kb/
|
|
// 08-ui-views.md §1's AdvancedOptionsView table) is a real category
|
|
// checklist filtering lOptionList by VTank's own SettingsCategories
|
|
// bitmask. "EnableLooting" carries ONLY bit 0x100 (256) in the
|
|
// embedded .usd; "EnableNav" carries only bit 0x20 (32) — disabling
|
|
// every category except 0x100 must hide EnableNav but keep
|
|
// EnableLooting visible.
|
|
var panel = new MossTankPanel(new FakeHost(new FakeAutomation()));
|
|
Assert.Contains("EnableLooting", panel.AdvancedOptionNames);
|
|
Assert.Contains("EnableNav", panel.AdvancedOptionNames);
|
|
|
|
for (int i = 0; i < panel.AdvancedOptionCategoryEnabled.Count; i++)
|
|
if (VtankOptionCatalog.CategoryBits[i] != 0x100)
|
|
panel.ToggleAdvancedOptionCategoryAt(i);
|
|
|
|
Assert.Contains("EnableLooting", panel.AdvancedOptionNames);
|
|
Assert.DoesNotContain("EnableNav", panel.AdvancedOptionNames);
|
|
|
|
// Re-enabling everything restores the full list.
|
|
for (int i = 0; i < panel.AdvancedOptionCategoryEnabled.Count; i++)
|
|
if (!panel.AdvancedOptionCategoryEnabled[i])
|
|
panel.ToggleAdvancedOptionCategoryAt(i);
|
|
Assert.Contains("EnableNav", panel.AdvancedOptionNames);
|
|
}
|
|
|
|
[Fact]
|
|
public void AdvancedOptionCategoryEnabledIsNotTheMutableBackingArray()
|
|
{
|
|
// Fix round C item D4: AdvancedOptionCategoryEnabled used to return
|
|
// the panel's own backing bool[] directly — any caller could cast
|
|
// the returned IReadOnlyList<bool> back to bool[] and flip flags
|
|
// without ever going through ToggleAdvancedOptionCategoryAt (which
|
|
// also keeps _selectedAdvancedOption clamped and the caches fresh).
|
|
// It is now a ReadOnlyCollection<bool> view: casting it back to
|
|
// bool[] must throw, not silently hand out a mutable reference.
|
|
var panel = new MossTankPanel(new FakeHost(new FakeAutomation()));
|
|
|
|
IReadOnlyList<bool> categoryEnabled = panel.AdvancedOptionCategoryEnabled;
|
|
|
|
Assert.Throws<InvalidCastException>(() => (bool[])categoryEnabled);
|
|
}
|
|
|
|
[Fact]
|
|
public void AdvancedOptionDescriptionSurfacesRealRetailHelpText()
|
|
{
|
|
// Fix round B item 9: txtInfo (384x80) now shows VTank's own real
|
|
// Settings.Description text (93 of 137 rows non-empty) instead of a
|
|
// bare name-only label.
|
|
var panel = new MossTankPanel(new FakeHost(new FakeAutomation()));
|
|
int index = panel.AdvancedOptionNames.ToList().IndexOf("DoHelp");
|
|
Assert.True(index >= 0);
|
|
|
|
panel.SelectAdvancedOption(index);
|
|
|
|
Assert.StartsWith("DoHelp:", panel.AdvancedOptionDescription, StringComparison.Ordinal);
|
|
Assert.Contains("fellowship", panel.AdvancedOptionDescription, StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
[Fact]
|
|
public void AdvancedOptionValueColumnMirrorsTheLiveSettingValue()
|
|
{
|
|
// Fix round B item 9: lOptionList gained a real clVal value column
|
|
// (AdvancedOptionValueColumn) alongside the name column.
|
|
// Fix round C item D1: the column is now materialized once by
|
|
// RefreshAdvancedOptions (category toggle / edit-apply / selection
|
|
// change / profile load), not recomputed on every read — so a
|
|
// setting changed OUTSIDE those four mutators (ToggleCombatEnabled
|
|
// is a main Options-tab checkbox, not a popup action) is picked up
|
|
// the next time a real popup mutator runs rather than on the very
|
|
// next property read. Re-selecting the same row exercises exactly
|
|
// that "selection change" mutator.
|
|
var panel = new MossTankPanel(new FakeHost(new FakeAutomation()));
|
|
int index = panel.AdvancedOptionNames.ToList().IndexOf("EnableCombat");
|
|
Assert.True(index >= 0);
|
|
string before = panel.AdvancedOptionValueColumn[index];
|
|
Assert.Equal(panel.CombatEnabled ? "True" : "False", before);
|
|
|
|
panel.ToggleCombatEnabled();
|
|
panel.SelectAdvancedOption(index);
|
|
|
|
string after = panel.AdvancedOptionValueColumn[index];
|
|
Assert.NotEqual(before, after);
|
|
Assert.Equal(panel.CombatEnabled ? "True" : "False", after);
|
|
}
|
|
|
|
[Fact]
|
|
public void AdvancedOptionsPopupBindingsDoNotReallocateOnEveryRead()
|
|
{
|
|
// Fix round C item D1: AdvancedOptionNames/AdvancedOptionValueColumn
|
|
// used to re-run FilteredAdvancedOptionNames() (a Where over the
|
|
// full 163-entry VtankOptionCatalog) — and, for the value column, a
|
|
// fresh GetMetaOption(name).ToDisplayString() per row — on every
|
|
// single retained-UI draw of the popup. Both are now materialized
|
|
// once in RefreshAdvancedOptions: repeated reads with no mutation
|
|
// in between must return the SAME array instance, matching the
|
|
// precedent MetaAndRouteGridColumnsDoNotReallocateOnEveryRead set
|
|
// for item 12's grids.
|
|
var panel = new MossTankPanel(new FakeHost(new FakeAutomation()));
|
|
|
|
Assert.Same(panel.AdvancedOptionNames, panel.AdvancedOptionNames);
|
|
Assert.Same(panel.AdvancedOptionValueColumn, panel.AdvancedOptionValueColumn);
|
|
|
|
int index = panel.AdvancedOptionNames.ToList().IndexOf("EnableCombat");
|
|
Assert.True(index >= 0);
|
|
panel.SelectAdvancedOption(index);
|
|
|
|
// A real mutator (selection change) is allowed to swap the cached
|
|
// instance out for a fresh one...
|
|
IReadOnlyList<string> namesAfterSelect = panel.AdvancedOptionNames;
|
|
IReadOnlyList<string> valuesAfterSelect = panel.AdvancedOptionValueColumn;
|
|
// ...but once settled, repeated reads must again share one instance.
|
|
Assert.Same(namesAfterSelect, panel.AdvancedOptionNames);
|
|
Assert.Same(valuesAfterSelect, panel.AdvancedOptionValueColumn);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Campaign VT slice 7 S7.2: VTank's cOn (Options tab, "Run Macro") is a
|
|
/// Checkbox with a STATIC caption whose checked state reflects whether
|
|
/// the macro is running — our own control was a push-button with a
|
|
/// dynamic caption and no bool a <toggle checked=...> could bind to.
|
|
/// </summary>
|
|
[Fact]
|
|
public void CombatMacroRunningReflectsTheSameStateAsCombatButtonText()
|
|
{
|
|
var panel = new MossTankPanel(new FakeHost(new FakeAutomation(), new MemoryStorage()));
|
|
|
|
Assert.False(panel.CombatMacroRunning);
|
|
Assert.Equal("Run Macro", panel.CombatButtonText);
|
|
|
|
panel.ToggleCombat();
|
|
|
|
Assert.True(panel.CombatMacroRunning);
|
|
Assert.Equal("Stop Macro", panel.CombatButtonText);
|
|
}
|
|
|
|
[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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Reproduces MossTankLootProfileStore's pre-cutover hashed JSON key
|
|
/// (its own private <c>ProfileKey</c> is 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 LegacyLootProfileKey(string value, bool byCharacter)
|
|
{
|
|
string identity = (byCharacter ? "char:" : "named:") + value.Trim().ToUpperInvariant();
|
|
string hash = Convert.ToHexString(
|
|
System.Security.Cryptography.SHA256.HashData(
|
|
System.Text.Encoding.UTF8.GetBytes(identity)));
|
|
return $"profiles/loot/{hash}.json";
|
|
}
|
|
|
|
/// <summary>
|
|
/// Round 3 item 9: unlike Meta/Route (whose pre-cutover roster was
|
|
/// already abandoned before their own cutovers), loot's
|
|
/// "profiles/loot/index.json" was still the LIVE mechanism right up to
|
|
/// this exact commit, so this single one-time sweep must convert BOTH
|
|
/// this character's own "By char" JSON document AND every other named
|
|
/// profile the roster still lists — not just whichever one happened to
|
|
/// be selected.
|
|
/// </summary>
|
|
[Fact]
|
|
public void LootRosterSweepConvertsByCharacterAndEveryNamedLegacyProfileOnce()
|
|
{
|
|
var storage = new MemoryStorage();
|
|
var automation = new FakeAutomation { Name = "Barris" };
|
|
storage.Text[LegacyLootProfileKey("Barris", byCharacter: true)] = """
|
|
{
|
|
"Rules": [
|
|
{ "Name": "Coins", "Expression": "name ~= coin", "Action": 0, "Priority": 3 }
|
|
]
|
|
}
|
|
""";
|
|
storage.Text["profiles/loot/index.json"] = """
|
|
{
|
|
"Version": 1,
|
|
"Names": ["Farming"],
|
|
"SelectedByCharacter": {}
|
|
}
|
|
""";
|
|
storage.Text[LegacyLootProfileKey("Farming", byCharacter: false)] = """
|
|
{
|
|
"Rules": [
|
|
{ "Name": "Salvage", "Expression": "name ~= salvage", "Action": 0, "Priority": 1 }
|
|
]
|
|
}
|
|
""";
|
|
|
|
var panel = new MossTankPanel(new FakeHost(automation, storage));
|
|
|
|
string byCharacterFile = VtankProfileDirectory.AutoCharacterFileName(
|
|
"Barris", string.Empty, "utl");
|
|
Assert.True(storage.Text.ContainsKey(byCharacterFile));
|
|
Assert.True(storage.Text.ContainsKey("Farming.utl"));
|
|
Assert.False(storage.Text.ContainsKey(LegacyLootProfileKey("Barris", byCharacter: true)));
|
|
Assert.False(storage.Text.ContainsKey(LegacyLootProfileKey("Farming", byCharacter: false)));
|
|
Assert.False(storage.Text.ContainsKey("profiles/loot/index.json"));
|
|
|
|
// The "By char" profile is already loaded (panel construction binds
|
|
// and loads it); the raw .utl round-trips VTClassic-side, but the
|
|
// human expression text is only visible through the store's own
|
|
// load path (MossTankLootProfileStore.ApplyMossTankExpressions),
|
|
// which the panel's rule rows expose.
|
|
Assert.Contains("name ~= coin", Assert.Single(panel.LootRuleRows), StringComparison.Ordinal);
|
|
|
|
panel.SelectLootProfile("Farming");
|
|
Assert.Contains("name ~= salvage", Assert.Single(panel.LootRuleRows), StringComparison.Ordinal);
|
|
|
|
// Idempotent: a fresh panel against the same storage sweeps nothing
|
|
// more (there is no roster key left to read) and keeps both values.
|
|
var reloaded = new MossTankPanel(new FakeHost(
|
|
new FakeAutomation { Name = "Barris" }, storage));
|
|
Assert.Contains("name ~= coin", Assert.Single(reloaded.LootRuleRows), StringComparison.Ordinal);
|
|
}
|
|
|
|
[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);
|
|
// Round 3 item 9: a named loot profile is now a real .utl file, not
|
|
// a hashed JSON document under profiles/loot/.
|
|
Assert.True(storage.Text.ContainsKey("Currency.utl"));
|
|
}
|
|
|
|
[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);
|
|
// Round 3 item 9: the imported profile is now written directly as
|
|
// the real "Legacy.utl" file — there is no separate exports/
|
|
// mirror any more (WriteLegacyExport is deleted).
|
|
string exported = storage.Text["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);
|
|
}
|
|
|
|
// Round 3 item 10: Meta/Route/Loot get the same Delete action Settings
|
|
// already had from round 2 step 5.
|
|
[Fact]
|
|
public void DeleteRouteProfileRemovesTheRealFileAndFallsBackToByCharacter()
|
|
{
|
|
var storage = new MemoryStorage();
|
|
var panel = new MossTankPanel(new FakeHost(new FakeAutomation(), storage));
|
|
panel.SetRouteProfileNameDraft("Fellowship");
|
|
panel.CopyRouteProfile();
|
|
Assert.Equal("Fellowship", panel.SelectedRouteProfile);
|
|
Assert.Contains("Fellowship", panel.RouteProfileNames);
|
|
Assert.True(storage.Text.ContainsKey("navs/Fellowship.af"));
|
|
|
|
panel.DeleteRouteProfile();
|
|
|
|
Assert.Equal(MossTankRouteProfileStore.ByCharacter, panel.SelectedRouteProfile);
|
|
Assert.DoesNotContain("Fellowship", panel.RouteProfileNames);
|
|
Assert.False(storage.Text.ContainsKey("navs/Fellowship.af"));
|
|
Assert.Contains("Deleted", panel.RouteNotice, StringComparison.Ordinal);
|
|
}
|
|
|
|
[Fact]
|
|
public void DeleteRouteProfileRefusesToRemoveByCharacter()
|
|
{
|
|
var storage = new MemoryStorage();
|
|
var panel = new MossTankPanel(new FakeHost(new FakeAutomation(), storage));
|
|
|
|
panel.DeleteRouteProfile();
|
|
|
|
Assert.Equal(MossTankRouteProfileStore.ByCharacter, panel.SelectedRouteProfile);
|
|
Assert.Contains("cannot be deleted", panel.RouteNotice, StringComparison.Ordinal);
|
|
}
|
|
|
|
[Fact]
|
|
public void DeleteMetaProfileRemovesTheRealFileAndFallsBackToByCharacter()
|
|
{
|
|
var storage = new MemoryStorage();
|
|
var panel = new MossTankPanel(new FakeHost(new FakeAutomation(), storage));
|
|
panel.SetMetaProfileNameDraft("Fellowship");
|
|
panel.CopyMetaProfile();
|
|
Assert.Equal("Fellowship", panel.SelectedMetaProfile);
|
|
Assert.Contains("Fellowship", panel.MetaProfileNames);
|
|
Assert.True(storage.Text.ContainsKey("metas/Fellowship.af"));
|
|
|
|
panel.DeleteMetaProfile();
|
|
|
|
Assert.Equal(MossTankMetaProfileStore.ByCharacter, panel.SelectedMetaProfile);
|
|
Assert.DoesNotContain("Fellowship", panel.MetaProfileNames);
|
|
Assert.False(storage.Text.ContainsKey("metas/Fellowship.af"));
|
|
Assert.Contains("Deleted", panel.MetaNotice, StringComparison.Ordinal);
|
|
}
|
|
|
|
[Fact]
|
|
public void DeleteMetaProfileRefusesToRemoveByCharacter()
|
|
{
|
|
var storage = new MemoryStorage();
|
|
var panel = new MossTankPanel(new FakeHost(new FakeAutomation(), storage));
|
|
|
|
panel.DeleteMetaProfile();
|
|
|
|
Assert.Equal(MossTankMetaProfileStore.ByCharacter, panel.SelectedMetaProfile);
|
|
Assert.Contains("cannot be deleted", panel.MetaNotice, StringComparison.Ordinal);
|
|
}
|
|
|
|
[Fact]
|
|
public void DeleteLootProfileRemovesTheRealFileAndFallsBackToByCharacter()
|
|
{
|
|
var storage = new MemoryStorage();
|
|
var panel = new MossTankPanel(new FakeHost(new FakeAutomation(), storage));
|
|
panel.SetLootProfileNameDraft("Fellowship");
|
|
panel.CopyLootProfile();
|
|
Assert.Equal("Fellowship", panel.LootProfileName);
|
|
Assert.Contains("Fellowship", panel.LootProfileNames);
|
|
|
|
panel.DeleteLootProfile();
|
|
|
|
Assert.Equal(MossTankLootProfileStore.ByCharacter, panel.LootProfileName);
|
|
Assert.DoesNotContain("Fellowship", panel.LootProfileNames);
|
|
Assert.False(storage.Text.ContainsKey("Fellowship.utl"));
|
|
Assert.Contains("Deleted", panel.LootEditorNotice, StringComparison.Ordinal);
|
|
}
|
|
|
|
[Fact]
|
|
public void DeleteLootProfileRefusesToRemoveByCharacter()
|
|
{
|
|
var storage = new MemoryStorage();
|
|
var panel = new MossTankPanel(new FakeHost(new FakeAutomation(), storage));
|
|
|
|
panel.DeleteLootProfile();
|
|
|
|
Assert.Equal(MossTankLootProfileStore.ByCharacter, panel.LootProfileName);
|
|
Assert.Contains("cannot be deleted", panel.LootEditorNotice, 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;
|
|
// owner decision 2026-09-07 (slice 1c): this is the real route file,
|
|
// living under navs/ 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["navs/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" (now "navs/Foo.af.af"
|
|
// vs "navs/Foo.af" under the slice 1c folder layout).
|
|
[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("navs/Foo.af"));
|
|
Assert.False(storage.Text.ContainsKey("navs/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. Owner decision 2026-09-07 (slice 1c)
|
|
// resolved this permanently: Meta lives under metas/, routes under
|
|
// navs/ — two different subfolders of the same VtankProfiles
|
|
// directory, so the same bare name in each never collides.
|
|
[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("metas/Same.af"));
|
|
Assert.True(storage.Text.ContainsKey("navs/Same.af"));
|
|
Assert.Contains(
|
|
"STATE: ",
|
|
storage.Text["metas/Same.af"],
|
|
StringComparison.Ordinal);
|
|
Assert.Contains(
|
|
"NAV: ",
|
|
storage.Text["navs/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, living under metas/ per the slice 1c
|
|
// owner decision).
|
|
Command(panel, "meta save Exported.met");
|
|
Assert.Contains(
|
|
"STATE: ",
|
|
storage.Text["metas/Exported.af"],
|
|
StringComparison.Ordinal);
|
|
Assert.True(MetafSerializer.TryLoadMeta(
|
|
storage.Text["metas/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" (now "metas/Foo.af.af" vs "metas/Foo.af"
|
|
// under the slice 1c folder layout).
|
|
[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("metas/Foo.af"));
|
|
Assert.False(storage.Text.ContainsKey("metas/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 MonstersGridMutationsPersistAcrossSessions()
|
|
{
|
|
// Campaign VT S7.3: the select-then-edit surface (SelectMonsterRule,
|
|
// ApplyMonsterRule, MonsterPriorityUp, SelectMonsterDamage, ...) is
|
|
// retired in favor of VTank's own per-cell grid (docs/research/
|
|
// vtank-kb/08-ui-views.md §1 "Tab: Monsters"). This test proves the
|
|
// replacement round-trips through the same CombatSettings.Rules
|
|
// persistence the old editor used.
|
|
var storage = new MemoryStorage();
|
|
var first = new MossTankPanel(new FakeHost(
|
|
new FakeAutomation { Name = "Rule Maker" },
|
|
storage));
|
|
|
|
first.SetMonsterExpressionDraft("species==drudge");
|
|
first.AddMonsterRule(); // row 1: "species==drudge"
|
|
first.ToggleMonsterImperilAt(1);
|
|
first.CycleMonsterDamageAt(1); // Auto -> Void Basic (MonsterDamageCycle order)
|
|
first.CycleMonsterPriorityAt(1); // 0 -> 1
|
|
|
|
Assert.Equal(["DEFAULT", "species==drudge"], first.MonsterNameColumn);
|
|
Assert.True(first.MonsterImperilColumn[1]);
|
|
Assert.Equal("Void Basic", first.MonsterDamageColumn[1]);
|
|
Assert.Equal("1", first.MonsterPriorityColumn[1]);
|
|
// Add clears the draft so a stray re-click doesn't add it twice.
|
|
Assert.Equal(string.Empty, first.MonsterExpressionDraft);
|
|
|
|
var second = new MossTankPanel(new FakeHost(
|
|
new FakeAutomation { Name = "Rule Maker" },
|
|
storage));
|
|
|
|
Assert.Equal(["DEFAULT", "species==drudge"], second.MonsterNameColumn);
|
|
Assert.True(second.MonsterImperilColumn[1]);
|
|
Assert.Equal("Void Basic", second.MonsterDamageColumn[1]);
|
|
Assert.Equal("1", second.MonsterPriorityColumn[1]);
|
|
}
|
|
|
|
[Fact]
|
|
public void ToggleMonsterFlagAtWritesOnlyTheTargetedRow()
|
|
{
|
|
var panel = new MossTankPanel(new FakeHost(new FakeAutomation()));
|
|
panel.AddMonsterRule(); // row 1: "New monster" (empty draft default)
|
|
|
|
Assert.False(panel.MonsterImperilColumn[0]);
|
|
Assert.False(panel.MonsterImperilColumn[1]);
|
|
|
|
panel.ToggleMonsterImperilAt(1);
|
|
|
|
Assert.False(panel.MonsterImperilColumn[0]);
|
|
Assert.True(panel.MonsterImperilColumn[1]);
|
|
|
|
panel.ToggleMonsterImperilAt(1);
|
|
Assert.False(panel.MonsterImperilColumn[1]);
|
|
}
|
|
|
|
[Fact]
|
|
public void CycleMonsterPriorityAtWrapsExactlyNegativeOneThroughFour()
|
|
{
|
|
// PluginCore.cs:7956-7967 (case 15): -1 -> 0 -> 1 -> 2 -> 3 -> 4 -> -1.
|
|
var panel = new MossTankPanel(new FakeHost(new FakeAutomation()));
|
|
Assert.Equal("0", panel.MonsterPriorityColumn[0]); // DEFAULT starts at 0
|
|
|
|
foreach (string next in new[] { "1", "2", "3", "4", "-1", "0" })
|
|
{
|
|
panel.CycleMonsterPriorityAt(0);
|
|
Assert.Equal(next, panel.MonsterPriorityColumn[0]);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void MonsterDamageColumnsCycleInTheExactRetailOrder()
|
|
{
|
|
// PluginCore.cs:7968-8089 (cases 16/17/20) via eDamageElement's own
|
|
// wraparound (refs/vtank/decompiled/uTank2/eDamageElement.cs) — three
|
|
// DIFFERENT sequences, each asserted in full before it wraps.
|
|
var panel = new MossTankPanel(new FakeHost(new FakeAutomation()));
|
|
|
|
AssertCyclesExactly(
|
|
() => panel.MonsterDamageColumn[0],
|
|
() => panel.CycleMonsterDamageAt(0),
|
|
[
|
|
"Pierce", "Bludgeon", "Slash", "Acid", "Lightning", "Cold",
|
|
"Fire", "Harm", "Auto", "Void Basic", "Drain Auto",
|
|
"Prismatic", "Random", "Fists",
|
|
]);
|
|
AssertCyclesExactly(
|
|
() => panel.MonsterExtraVulnColumn[0],
|
|
() => panel.CycleMonsterExtraVulnerabilityAt(0),
|
|
["Pierce", "Bludgeon", "Slash", "Acid", "Lightning", "Cold", "Fire", "Auto", "None"]);
|
|
AssertCyclesExactly(
|
|
() => panel.MonsterPetDamageColumn[0],
|
|
() => panel.CycleMonsterPetDamageAt(0),
|
|
[
|
|
"Pierce", "Bludgeon", "Slash", "Acid", "Lightning", "Cold",
|
|
"Fire", "PAuto", "Auto", "None",
|
|
]);
|
|
|
|
static void AssertCyclesExactly(
|
|
Func<string> readCurrent, Action cycle, string[] expected)
|
|
{
|
|
// Cycle until row 0 reads the array's own first entry, so every
|
|
// subsequent step lines up with `expected` regardless of the
|
|
// rule's starting value (DamageType/PetDamageType default to
|
|
// different sentinels — Auto vs PAuto — that are not index 0).
|
|
// `readCurrent` re-reads the live column on every call — the
|
|
// column property itself returns a fresh snapshot array on each
|
|
// get, so capturing one snapshot up front would freeze `[0]`
|
|
// forever and spin this loop.
|
|
int guard = 0;
|
|
while (readCurrent() != expected[0])
|
|
{
|
|
cycle();
|
|
Assert.True(++guard <= expected.Length, "cycle never reached the array's first entry");
|
|
}
|
|
foreach (string next in expected.Skip(1).Append(expected[0]))
|
|
{
|
|
cycle();
|
|
Assert.Equal(next, readCurrent());
|
|
}
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void DeleteMonsterRuleAtRemovesNonDefaultRowsButNeverDefault()
|
|
{
|
|
// PluginCore.cs:7948-7955 (case 14) + the row-delete helper at
|
|
// PluginCore.cs:8168-8179.
|
|
var panel = new MossTankPanel(new FakeHost(new FakeAutomation()));
|
|
panel.SetMonsterExpressionDraft("drudge");
|
|
panel.AddMonsterRule();
|
|
panel.SetMonsterExpressionDraft("mosswart");
|
|
panel.AddMonsterRule();
|
|
Assert.Equal(["DEFAULT", "drudge", "mosswart"], panel.MonsterNameColumn);
|
|
|
|
panel.DeleteMonsterRuleAt(0); // DEFAULT: refused
|
|
Assert.Equal(["DEFAULT", "drudge", "mosswart"], panel.MonsterNameColumn);
|
|
|
|
panel.DeleteMonsterRuleAt(1); // drudge
|
|
Assert.Equal(["DEFAULT", "mosswart"], panel.MonsterNameColumn);
|
|
}
|
|
|
|
[Fact]
|
|
public void VtankRefreshCommandRepopulatesTheMonstersGridAfterCreatingTheDefaultRule()
|
|
{
|
|
// Fix round C item D2: /mt refresh calls EnsureDefaultMonsterRule
|
|
// (a _combatSettings.Rules mutator) but used to skip
|
|
// RefreshMonsterEditor afterward — every OTHER Rules mutator in the
|
|
// panel (AddMonsterRuleCore, DeleteMonsterRuleAtCore,
|
|
// MoveMonsterRuleAtCore, UpdateMonsterActionsAt, the constructor,
|
|
// and ResetProfileConsumers's own EnsureDefaultMonsterRule call)
|
|
// already refreshes the grid right after mutating; this command
|
|
// handler was the one gap found by auditing every
|
|
// _combatSettings.Rules mutation site. Rules can never actually go
|
|
// empty through the panel's own public surface (delete refuses
|
|
// removing DEFAULT, and every profile-load path re-adds it via
|
|
// ResetProfileConsumers before this handler could ever observe it
|
|
// empty) — so this reaches the private CombatSettings instance
|
|
// directly to reproduce the corrupted/emptied-list precondition
|
|
// the command's own defensive EnsureDefaultMonsterRule() call
|
|
// exists to recover from. The cached _monsterNameColumn field is
|
|
// also poked to a sentinel value first: EnsureDefaultMonsterRule
|
|
// re-adding "DEFAULT" happens to reproduce the SAME content the
|
|
// grid already had from construction, so without also corrupting
|
|
// the cache this test would pass even with the refresh call
|
|
// missing (the stale cache and the freshly recomputed one would
|
|
// coincidentally agree).
|
|
var panel = new MossTankPanel(new FakeHost(new FakeAutomation()));
|
|
Type panelType = typeof(MossTankPanel);
|
|
var combatSettings = (CombatSettings)panelType
|
|
.GetField("_combatSettings", BindingFlags.Instance | BindingFlags.NonPublic)!
|
|
.GetValue(panel)!;
|
|
combatSettings.Rules.Clear();
|
|
FieldInfo monsterNameColumnField = panelType
|
|
.GetField("_monsterNameColumn", BindingFlags.Instance | BindingFlags.NonPublic)!;
|
|
monsterNameColumnField.SetValue(panel, new[] { "STALE" });
|
|
|
|
panel.ExecuteVtankCommand(new PluginCommand("vt", "refresh", "/vt refresh"));
|
|
|
|
Assert.Equal(["DEFAULT"], panel.MonsterNameColumn);
|
|
}
|
|
|
|
[Fact]
|
|
public void MoveMonsterRuleAtReordersButNeverDisplacesDefault()
|
|
{
|
|
// PluginCore.cs:8090-8105 (cases 21/22).
|
|
var panel = new MossTankPanel(new FakeHost(new FakeAutomation()));
|
|
panel.SetMonsterExpressionDraft("drudge");
|
|
panel.AddMonsterRule();
|
|
panel.SetMonsterExpressionDraft("mosswart");
|
|
panel.AddMonsterRule();
|
|
Assert.Equal(["DEFAULT", "drudge", "mosswart"], panel.MonsterNameColumn);
|
|
|
|
panel.MoveMonsterRuleDownAt(1); // drudge <-> mosswart
|
|
Assert.Equal(["DEFAULT", "mosswart", "drudge"], panel.MonsterNameColumn);
|
|
|
|
panel.MoveMonsterRuleUpAt(2); // back (row 2 "drudge" up to row 1)
|
|
Assert.Equal(["DEFAULT", "drudge", "mosswart"], panel.MonsterNameColumn);
|
|
|
|
panel.MoveMonsterRuleUpAt(1); // would displace DEFAULT from row 0: refused
|
|
Assert.Equal(["DEFAULT", "drudge", "mosswart"], panel.MonsterNameColumn);
|
|
|
|
Assert.Equal(3, panel.MonsterMoveUpIcons.Count);
|
|
Assert.All(panel.MonsterMoveUpIcons, id => Assert.Equal(0x060028FCu, id));
|
|
Assert.All(panel.MonsterMoveDownIcons, id => Assert.Equal(0x060028FDu, id));
|
|
}
|
|
|
|
[Fact]
|
|
public void AddMonsterRuleUsesTheDraftTextAndAddSelectedMonsterUsesTheWorldTarget()
|
|
{
|
|
var automation = new CombatCapableFakeAutomation
|
|
{
|
|
Targets = [new PluginCombatTarget(30, "Drudge", 700, 2f, 0f, true, 1f)],
|
|
};
|
|
var host = new FakeHost(automation);
|
|
var panel = new MossTankPanel(host);
|
|
|
|
panel.SetMonsterExpressionDraft("species==drudge");
|
|
panel.AddMonsterRule();
|
|
Assert.Equal(["DEFAULT", "species==drudge"], panel.MonsterNameColumn);
|
|
Assert.Equal(string.Empty, panel.MonsterExpressionDraft);
|
|
|
|
host.Selection.Select(30);
|
|
panel.AddSelectedMonster();
|
|
Assert.Equal(["DEFAULT", "species==drudge", "Drudge"], panel.MonsterNameColumn);
|
|
}
|
|
|
|
[Fact]
|
|
public void MonsterWeaponColumnCyclesTheRegisteredRosterAndPersistsByNameAcrossSessions()
|
|
{
|
|
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.AddSelectedItem(); // registers "Fire Sword" into the weapon roster
|
|
|
|
Assert.Equal("<AUTO>", first.MonsterWeaponColumn[0]);
|
|
first.CycleMonsterWeaponAt(0); // AUTO -> Fire Sword (only registered item)
|
|
Assert.Equal("Fire Sword", first.MonsterWeaponColumn[0]);
|
|
first.CycleMonsterWeaponAt(0); // Fire Sword -> AUTO (wraps)
|
|
Assert.Equal("<AUTO>", first.MonsterWeaponColumn[0]);
|
|
first.CycleMonsterWeaponAt(0); // back to Fire Sword for the persistence check
|
|
Assert.Equal("Fire Sword", first.MonsterWeaponColumn[0]);
|
|
|
|
var second = new MossTankPanel(new FakeHost(
|
|
new FakeAutomation
|
|
{
|
|
Name = "Rule Maker",
|
|
ItemEntries = [Item(99, "Fire Sword", 1)],
|
|
},
|
|
storage));
|
|
|
|
Assert.Equal("Fire Sword", second.MonsterWeaponColumn[0]);
|
|
}
|
|
|
|
[Fact]
|
|
public void MonsterGridColumnsDoNotScanLiveInventoryOrAllocateOnEveryRead()
|
|
{
|
|
// Fix round B item 12: every Monsters-grid column getter used to
|
|
// re-run a LINQ .Select(...).ToArray() over _combatSettings.Rules
|
|
// on every retained-UI draw; Weapon/Offhand additionally called
|
|
// ItemDisplayName (CaptureOwnedItems(), a live inventory scan) once
|
|
// per row per frame. All 23 columns are now materialized once in
|
|
// RefreshMonsterEditor: repeated reads must not re-scan inventory,
|
|
// and must return the SAME array instance (proof of no fresh
|
|
// allocation per read).
|
|
var automation = new FakeAutomation
|
|
{
|
|
Name = "Perf Check",
|
|
ItemEntries = [Item(10, "Fire Sword", 1)],
|
|
};
|
|
var host = new FakeHost(automation);
|
|
var panel = new MossTankPanel(host);
|
|
host.Selection.Select(10);
|
|
panel.AddSelectedItem();
|
|
panel.CycleMonsterWeaponAt(0); // populates the Weapon column with a real name
|
|
|
|
int callsAfterCycle = automation.CaptureOwnedItemsCallCount;
|
|
IReadOnlyList<string> weaponFirstRead = panel.MonsterWeaponColumn;
|
|
for (int i = 0; i < 5; i++)
|
|
{
|
|
_ = panel.MonsterFesterColumn;
|
|
_ = panel.MonsterNameColumn;
|
|
_ = panel.MonsterWeaponColumn;
|
|
_ = panel.MonsterOffhandColumn;
|
|
_ = panel.MonsterMoveUpIcons;
|
|
}
|
|
|
|
Assert.Equal(callsAfterCycle, automation.CaptureOwnedItemsCallCount);
|
|
Assert.Same(weaponFirstRead, panel.MonsterWeaponColumn);
|
|
}
|
|
|
|
[Fact]
|
|
public void RouteGridDeletesByAnyCellClickAndSelectsNearestByDistance()
|
|
{
|
|
// Campaign VT S7.5: VTank's own clWP/clWPc 2-column grid
|
|
// (PluginCore.cs:3576-3599 — any cell deletes) plus
|
|
// btnNavResetPoint's UI-scoped "Select Nearest Point" adaptation.
|
|
var automation = new FakeAutomation { NavigationSnapshot = NavigationAt(0f) };
|
|
var panel = new MossTankPanel(new FakeHost(automation));
|
|
|
|
panel.AddRoutePoint(); // waypoint 0 at EastWest=0
|
|
automation.NavigationSnapshot = automation.NavigationSnapshot with
|
|
{
|
|
Position = automation.NavigationSnapshot.Position with { EastWest = 50d },
|
|
};
|
|
panel.AddRoutePoint(); // waypoint 1 at EastWest=50
|
|
Assert.Equal(2, panel.RouteWaypointTextColumn.Count);
|
|
Assert.Equal(["1", "2"], panel.RouteWaypointCountColumn);
|
|
|
|
automation.NavigationSnapshot = automation.NavigationSnapshot with
|
|
{
|
|
Position = automation.NavigationSnapshot.Position with { EastWest = 48d },
|
|
};
|
|
panel.SelectNearestRouteWaypoint();
|
|
Assert.Equal(1, panel.SelectedRouteWaypointIndex);
|
|
|
|
panel.DeleteRouteWaypointAt(0);
|
|
Assert.Single(panel.RouteWaypointTextColumn);
|
|
Assert.Equal(["1"], panel.RouteWaypointCountColumn);
|
|
}
|
|
|
|
[Fact]
|
|
public void RouteInsertModeControlsWhereANewWaypointLands()
|
|
{
|
|
// Fix round B item 8: VTank's own cmbNavInsertMode is a real
|
|
// 3-option <menu> (Add to End / Insert Above / Insert Below),
|
|
// replacing the old 2-state ToggleRouteAddPosition button.
|
|
var automation = new FakeAutomation { NavigationSnapshot = NavigationAt(0f) };
|
|
var panel = new MossTankPanel(new FakeHost(automation));
|
|
Assert.Equal("Add to End", panel.SelectedRouteInsertMode);
|
|
|
|
automation.NavigationSnapshot = automation.NavigationSnapshot with
|
|
{ Position = automation.NavigationSnapshot.Position with { EastWest = 0d } };
|
|
panel.AddRoutePoint(); // index 0 @ 0
|
|
automation.NavigationSnapshot = automation.NavigationSnapshot with
|
|
{ Position = automation.NavigationSnapshot.Position with { EastWest = 20d } };
|
|
panel.AddRoutePoint(); // index 1 @ 20 (default Add to End)
|
|
Assert.Equal(2, panel.RouteWaypointTextColumn.Count);
|
|
Assert.Contains("20", panel.RouteWaypointTextColumn[1]);
|
|
|
|
panel.SelectRouteInsertMode("Insert Above");
|
|
Assert.Equal("Insert Above", panel.SelectedRouteInsertMode);
|
|
panel.SelectRouteWaypoint(0); // the @0 waypoint
|
|
automation.NavigationSnapshot = automation.NavigationSnapshot with
|
|
{ Position = automation.NavigationSnapshot.Position with { EastWest = 99d } };
|
|
panel.AddRoutePoint(); // must land BEFORE index 0, not appended
|
|
Assert.Equal(3, panel.RouteWaypointTextColumn.Count);
|
|
Assert.Contains("99", panel.RouteWaypointTextColumn[0]);
|
|
|
|
panel.SelectRouteInsertMode("Insert Below");
|
|
Assert.Equal("Insert Below", panel.SelectedRouteInsertMode);
|
|
panel.SelectRouteWaypoint(0); // the @99 waypoint
|
|
automation.NavigationSnapshot = automation.NavigationSnapshot with
|
|
{ Position = automation.NavigationSnapshot.Position with { EastWest = 77d } };
|
|
panel.AddRoutePoint(); // must land right AFTER index 0
|
|
Assert.Equal(4, panel.RouteWaypointTextColumn.Count);
|
|
Assert.Contains("77", panel.RouteWaypointTextColumn[1]);
|
|
}
|
|
|
|
[Fact]
|
|
public void RoutePauseSecondsFieldParsesAndClampsInput()
|
|
{
|
|
// Fix round B item 8: VTank's own txtPauseWaypointTime is a real
|
|
// editable field defaulting to "5", replacing the old "-"/"+"
|
|
// stepper (RoutePauseDown/RoutePauseUp, now removed).
|
|
var panel = new MossTankPanel(new FakeHost(new FakeAutomation()));
|
|
Assert.Equal("5", panel.RoutePauseSecondsFieldText);
|
|
|
|
panel.SetRoutePauseSecondsText("12");
|
|
Assert.Equal("12", panel.RoutePauseSecondsFieldText);
|
|
|
|
panel.SetRoutePauseSecondsText("99999");
|
|
Assert.Equal("3600", panel.RoutePauseSecondsFieldText);
|
|
|
|
panel.SetRoutePauseSecondsText("not a number");
|
|
Assert.Equal("3600", panel.RoutePauseSecondsFieldText); // unchanged on bad input
|
|
}
|
|
|
|
[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 MetaGridColumnsMatchTheRulesAndDeleteMoveActOnTheClickedRow()
|
|
{
|
|
// Campaign VT S7.6: VTank's own 6-column lstMetaRules grid
|
|
// (PluginCore.cs:2218-2247) — col 0 delete, cols 1/2 move up/down,
|
|
// cols 3-5 open the editor for that row (SelectMetaRule, already
|
|
// covered by MetaTabEditsAndExecutesTheLiveStateMachine).
|
|
var panel = new MossTankPanel(new FakeHost(new FakeAutomation()));
|
|
|
|
panel.SetMetaStateDraft("Idle");
|
|
panel.SelectMetaAction(nameof(MetaActionKind.ChatCommand));
|
|
panel.SetMetaActionTextDraft("/mt one");
|
|
panel.AddMetaRule();
|
|
panel.SetMetaStateDraft("Hunt");
|
|
panel.SetMetaActionTextDraft("/mt two");
|
|
panel.AddMetaRule();
|
|
|
|
Assert.Equal(["Idle", "Hunt"], panel.MetaStateColumn);
|
|
Assert.Contains("/mt one", panel.MetaActionColumn[0], StringComparison.Ordinal);
|
|
Assert.Contains("/mt two", panel.MetaActionColumn[1], StringComparison.Ordinal);
|
|
Assert.Equal(["X", "X"], panel.MetaDeleteColumn);
|
|
Assert.All(panel.MetaMoveUpIcons, id => Assert.Equal(0x060028FCu, id));
|
|
Assert.All(panel.MetaMoveDownIcons, id => Assert.Equal(0x060028FDu, id));
|
|
|
|
panel.MoveMetaRuleDownAt(0); // "Idle" <-> "Hunt"
|
|
Assert.Equal(["Hunt", "Idle"], panel.MetaStateColumn);
|
|
panel.MoveMetaRuleUpAt(1); // back
|
|
Assert.Equal(["Idle", "Hunt"], panel.MetaStateColumn);
|
|
|
|
panel.DeleteMetaRuleAt(0); // "Idle"
|
|
Assert.Equal(["Hunt"], panel.MetaStateColumn);
|
|
}
|
|
|
|
[Fact]
|
|
public void MetaAndRouteGridColumnsDoNotReallocateOnEveryRead()
|
|
{
|
|
// Fix round B item 12: MetaStateColumn/MetaConditionColumn/
|
|
// MetaActionColumn/MetaDeleteColumn/MetaMoveUpIcons/
|
|
// MetaMoveDownIcons and RouteWaypointCountColumn used to rebuild a
|
|
// fresh array with LINQ on every retained-UI draw. All are now
|
|
// materialized once in RefreshMetaEditor/RefreshRouteEditor:
|
|
// repeated reads must return the SAME array instance.
|
|
var panel = new MossTankPanel(new FakeHost(new FakeAutomation
|
|
{
|
|
NavigationSnapshot = NavigationAt(0f),
|
|
}));
|
|
panel.SelectMetaAction(nameof(MetaActionKind.ChatCommand));
|
|
panel.SetMetaActionTextDraft("/mt one");
|
|
panel.AddMetaRule();
|
|
panel.AddRoutePoint();
|
|
|
|
Assert.Same(panel.MetaStateColumn, panel.MetaStateColumn);
|
|
Assert.Same(panel.MetaConditionColumn, panel.MetaConditionColumn);
|
|
Assert.Same(panel.MetaActionColumn, panel.MetaActionColumn);
|
|
Assert.Same(panel.MetaDeleteColumn, panel.MetaDeleteColumn);
|
|
Assert.Same(panel.MetaMoveUpIcons, panel.MetaMoveUpIcons);
|
|
Assert.Same(panel.MetaMoveDownIcons, panel.MetaMoveDownIcons);
|
|
Assert.Same(panel.RouteWaypointCountColumn, panel.RouteWaypointCountColumn);
|
|
}
|
|
|
|
[Fact]
|
|
public void MetaEditorPopupOpensOnCellClickOrCreateAndClosesOnApplyOrCancel()
|
|
{
|
|
// Fix round B item 5: the inline State/Condition/Action editor moved
|
|
// off the Meta tab into its own popup (mosstank-metaeditor.xml),
|
|
// gated by MetaEditorVisible. A grid text-cell click
|
|
// (SelectMetaRule, the row-int overload the <column onclick> uses)
|
|
// opens it; the tab's own "Create" button (CreateMetaRule) opens it
|
|
// too; Apply and Cancel both close it. DeleteMetaRuleAt/
|
|
// MoveMetaRuleUpAt/MoveMetaRuleDownAt call SelectMetaRuleCore
|
|
// directly (not through the SelectMetaRule wrapper) and must NOT
|
|
// pop the editor open.
|
|
var panel = new MossTankPanel(new FakeHost(new FakeAutomation()));
|
|
Assert.False(panel.MetaEditorVisible);
|
|
|
|
panel.CreateMetaRule();
|
|
Assert.True(panel.MetaEditorVisible);
|
|
Assert.Single(panel.MetaRows);
|
|
|
|
panel.HideMetaEditor();
|
|
Assert.False(panel.MetaEditorVisible);
|
|
|
|
panel.SelectMetaRule(0);
|
|
Assert.True(panel.MetaEditorVisible);
|
|
|
|
panel.ApplyMetaRule();
|
|
Assert.False(panel.MetaEditorVisible);
|
|
|
|
panel.SelectMetaRule(0);
|
|
Assert.True(panel.MetaEditorVisible);
|
|
panel.HideMetaEditor();
|
|
Assert.False(panel.MetaEditorVisible);
|
|
|
|
panel.DeleteMetaRuleAt(0);
|
|
Assert.False(panel.MetaEditorVisible);
|
|
}
|
|
|
|
[Fact]
|
|
public void SwitchingTabsClosesTheBuffPickerAndMetaEditorPopups()
|
|
{
|
|
// Fix round B item 16: SelectTab already cleared LootEditorVisible/
|
|
// AdvancedOptionsVisible when the user left a tab — the buff
|
|
// picker (S7.4) and the Meta rule editor (item 5) are the same
|
|
// "own popup" shape and were missing from that same guard, leaving
|
|
// a stale open popup from a tab the user just left.
|
|
var panel = new MossTankPanel(new FakeHost(new FakeAutomation()));
|
|
|
|
panel.ShowExtraBuffPicker();
|
|
Assert.True(panel.BuffPickerVisible);
|
|
panel.ShowOptions(); // switch away from Buffs
|
|
Assert.False(panel.BuffPickerVisible);
|
|
|
|
panel.ShowMeta();
|
|
panel.CreateMetaRule();
|
|
Assert.True(panel.MetaEditorVisible);
|
|
panel.ShowOptions(); // switch away from Meta
|
|
Assert.False(panel.MetaEditorVisible);
|
|
}
|
|
|
|
[Fact]
|
|
public void MetaCurrentStateMenuForcesTheLiveEngineIntoTheChosenState()
|
|
{
|
|
// VTank's own cmbMetaCurrentState (docs/research/vtank-kb/
|
|
// 08-ui-views.md §1 "Tab: Meta") is settable, not a read-only
|
|
// label — choosing a state here must force the live MetaEngine
|
|
// into it via MetaEngine.Transition, the same primitive
|
|
// vtsetmetastate[]/SetMetaState rules already use.
|
|
var panel = new MossTankPanel(new FakeHost(new FakeAutomation()));
|
|
panel.SetMetaStateDraft(MetaEngine.DefaultState);
|
|
panel.SelectMetaAction(nameof(MetaActionKind.ChatCommand));
|
|
panel.SetMetaActionTextDraft("/mt hi");
|
|
panel.AddMetaRule();
|
|
panel.SetMetaStateDraft("Hunt");
|
|
panel.AddMetaRule();
|
|
|
|
Assert.Contains("Hunt", panel.MetaCurrentStateNames);
|
|
Assert.Equal(MetaEngine.DefaultState, panel.SelectedMetaCurrentState);
|
|
|
|
panel.SetMetaCurrentState("Hunt");
|
|
|
|
Assert.Equal("Hunt", panel.SelectedMetaCurrentState);
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Round 3 item 6: retail's bk.a text (refs/vtank/decompiled/bk.cs:32)
|
|
/// is "Done saving setting X to all profiles. (Changed N profiles)" —
|
|
/// no "= value" suffix, unlike the plain "set" success message.
|
|
/// </summary>
|
|
[Fact]
|
|
public void VtankSetInAllReportsRetailsExactMessageText()
|
|
{
|
|
var automation = new FakeAutomation();
|
|
var panel = new MossTankPanel(new FakeHost(automation));
|
|
|
|
Command(panel, "opt setinall AttackDistance 0.03");
|
|
|
|
string message = Assert.Single(automation.Messages, static text =>
|
|
text.StartsWith("Done saving setting", StringComparison.Ordinal));
|
|
Assert.StartsWith(
|
|
"Done saving setting AttackDistance to all profiles. (Changed ",
|
|
message,
|
|
StringComparison.Ordinal);
|
|
Assert.EndsWith(" profiles)", message, StringComparison.Ordinal);
|
|
Assert.DoesNotContain("=", message, StringComparison.Ordinal);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Round 3 item 6: a value that does not parse as the catalog's own
|
|
/// declared type (EnableLooting is Bool) fails with retail's exact
|
|
/// text (refs/vtank/decompiled/uTank2/PluginCore.cs:5501,5508,5612).
|
|
/// </summary>
|
|
[Theory]
|
|
[InlineData("set")]
|
|
[InlineData("setinall")]
|
|
public void VtankOptSetRejectsAWrongTypedValueWithRetailsExactText(string operation)
|
|
{
|
|
var automation = new FakeAutomation();
|
|
var panel = new MossTankPanel(new FakeHost(automation));
|
|
|
|
Command(panel, $"opt {operation} EnableLooting notaboolean");
|
|
|
|
Assert.Equal(
|
|
"Option set: Invalid value specified. Proper type of EnableLooting is System.Boolean.",
|
|
Assert.Single(automation.Messages));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Round 3 item 6: <c>bk.a</c> (refs/vtank/decompiled/bk.cs:22-27)
|
|
/// APPENDS a new Settings row — [0]=name, [1]=value, every other column
|
|
/// left as a void <c>gy()</c> cell — when a <c>.usd</c> file's Settings
|
|
/// table has no row for the option at all, rather than skipping that
|
|
/// file outright.
|
|
/// </summary>
|
|
[Fact]
|
|
public void SetOptionInAllAppendsARowWhenAFileHasNoneForThatSetting()
|
|
{
|
|
var storage = new MemoryStorage();
|
|
var automation = new FakeAutomation { Name = "Barris" };
|
|
// A hand-trimmed Settings table (4 real columns: Setting, Value,
|
|
// Description, SettingType) with exactly one row, for a DIFFERENT
|
|
// setting — "EnableLooting" is deliberately absent.
|
|
const string minimalUsd =
|
|
"1\r\nSettings\r\n4\r\nSetting\r\nValue\r\nDescription\r\nSettingType\r\n"
|
|
+ "y\r\nn\r\nn\r\nn\r\n1\r\ns\r\nEnableNav\r\nb\r\nFalse\r\ns\r\n\r\ni\r\n1\r\n";
|
|
storage.Text["Other.usd"] = minimalUsd;
|
|
|
|
var store = new MossTankProfileStore(new FakeHost(automation, storage));
|
|
var settings = new VtankSettingsProfileSerializer.AllSettings
|
|
{
|
|
Combat = new CombatSettings(),
|
|
Buffs = new BuffSettings(),
|
|
Vitals = new VitalSettings(),
|
|
Inventory = new InventorySettings(),
|
|
Navigation = new NavigationSettings(),
|
|
};
|
|
settings.Inventory.Loot.Enabled = true;
|
|
|
|
int count = store.SetOptionInAll("EnableLooting", settings);
|
|
|
|
VtankDatabase rewritten = VtankDatabase.Parse(storage.Text["Other.usd"]);
|
|
VtankTable table = rewritten.Find("Settings")!;
|
|
int nameColumn = table.ColumnIndex("Setting");
|
|
int valueColumn = table.ColumnIndex("Value");
|
|
VtankRow appended = Assert.Single(table.Rows, row =>
|
|
row.Cells[nameColumn].AsString().Equals(
|
|
"EnableLooting", StringComparison.OrdinalIgnoreCase));
|
|
Assert.True(appended.Cells[valueColumn].AsBool());
|
|
// The remaining columns (Description/SettingType) stay void, never
|
|
// copied from the existing EnableNav row.
|
|
Assert.Equal("0", appended.Cells[2].Tag);
|
|
Assert.Equal("0", appended.Cells[3].Tag);
|
|
Assert.True(count >= 1);
|
|
}
|
|
|
|
[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;
|
|
// Round 3 item 12: exposed as the concrete FakeLogger (not just the
|
|
// IPluginHost.Log interface view) so a test can inspect Warnings.
|
|
public FakeLogger Logger { get; } = new();
|
|
public IPluginLogger Log => Logger;
|
|
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;
|
|
// Fix round B item 12: counts CaptureOwnedItems calls so a test can
|
|
// prove a column getter is a plain field read (no live inventory
|
|
// scan) rather than per-row/per-frame work.
|
|
public int CaptureOwnedItemsCallCount { get; private set; }
|
|
public IReadOnlyList<PluginInventoryItem> CaptureOwnedItems()
|
|
{
|
|
CaptureOwnedItemsCallCount++;
|
|
return 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/CycleMonsterWeaponAt 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
|
|
{
|
|
// Round 3 item 12: capture Warn() calls so a test can prove a
|
|
// corrupt monster-rule expression is logged rather than silently
|
|
// swallowed by the two former bare "catch (FormatException) { }"
|
|
// blocks in MossTankProfileStore.
|
|
public List<string> Warnings { get; } = [];
|
|
public void Info(string message) { }
|
|
public void Warn(string message) => Warnings.Add(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;
|
|
}
|
|
}
|
|
}
|