Owner's second live look: "Buff_Profile banes and prots got duplicated. Only the one with ALL works as intended." BuffProfile-Prots/-Banes are tString rows (VtankOptionCatalog.DeclaredType == String); VTank's own Advanced Options list has NO case for tString at all (KB 01-settings-and-profiles.md, db.cs:132-166 for the list switch and :201-203 for the click handler — both "Not exposed"), so the hyphen rows never belonged in the list next to their real tEnum twins (BuffProfile_Prots/_Banes) — that mismatched pair is the "duplicate" the owner saw. FilteredAdvancedOptionNames now excludes every tString row (also catches BlacklistedSpellComps, the catalog's third tString entry); the enum rows stay listed and clickable exactly as before. tString settings are unaffected otherwise — still real, still settable via `/vt opt set`, only no longer rendered in this list. Mutation shown to fail first: the new AdvancedOptionListHidesTStringSettingsButKeepsTheirEnumCounterparts test asserted BuffProfile-Prots/-Banes/BlacklistedSpellComps absent and the two enum names present against the UNCHANGED filter — failed with "BuffProfile-Prots" found in the collection — then passed once FilteredAdvancedOptionNames gained the DeclaredType != String guard. MossTank suite 722 -> 723 (one new pin); App markup/plugin filter 258/258 (unaffected — no App-side markup change). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
3798 lines
155 KiB
C#
3798 lines
155 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 AdvancedOptionListHidesTStringSettingsButKeepsTheirEnumCounterparts()
|
|
{
|
|
// Owner's second live look (2026-09-07): "Buff_Profile banes and
|
|
// prots got duplicated. Only the one with ALL works as intended."
|
|
// BuffProfile-Prots/BuffProfile-Banes are tString rows
|
|
// (VtankSettingValueType.String); VTank's own Advanced Options
|
|
// list has NO case for tString at all (KB 01,
|
|
// db.cs:132-166/:201-203 — "Not exposed"), so they never
|
|
// belonged in the list to begin with — that is the "duplicate"
|
|
// the owner saw, not a real second setting. Every tString row
|
|
// in the catalog (BuffProfile-Prots, BuffProfile-Banes,
|
|
// BlacklistedSpellComps) follows the same rule; the enum
|
|
// counterparts (BuffProfile_Prots/_Banes) stay listed and
|
|
// clickable exactly as before.
|
|
var panel = new MossTankPanel(new FakeHost(new FakeAutomation()));
|
|
|
|
Assert.DoesNotContain("BuffProfile-Prots", panel.AdvancedOptionNames);
|
|
Assert.DoesNotContain("BuffProfile-Banes", panel.AdvancedOptionNames);
|
|
Assert.DoesNotContain("BlacklistedSpellComps", panel.AdvancedOptionNames);
|
|
|
|
Assert.Contains("BuffProfile_Prots", panel.AdvancedOptionNames);
|
|
Assert.Contains("BuffProfile_Banes", 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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// D-5 (round E architecture re-check): RefreshAdvancedOptions' own
|
|
/// doc comment already lists every real mutation site that must call
|
|
/// it — category toggle, edit/apply, selection change, and profile
|
|
/// load — but opening the popup itself (ShowAdvancedOptions) was
|
|
/// missing. A setting changed via the Options tab (ToggleCombatEnabled,
|
|
/// the same mutator <see cref="AdvancedOptionValueColumnMirrorsTheLiveSettingValue"/>
|
|
/// exercises through a "selection change") while the popup was closed
|
|
/// must already read correctly the FIRST time it opens — not only
|
|
/// after some in-popup interaction happens to refresh it.
|
|
/// </summary>
|
|
[Fact]
|
|
public void ShowAdvancedOptionsRefreshesValuesChangedWhileThePopupWasClosed()
|
|
{
|
|
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.ShowAdvancedOptions();
|
|
|
|
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>
|
|
/// Round D item 2 (owner's VTank interaction model): clicking a
|
|
/// tBool row's VALUE cell flips the setting in place — no row
|
|
/// selection needed, no edit field involved.
|
|
/// </summary>
|
|
[Fact]
|
|
public void ClickAdvancedOptionValue_OnBoolRow_FlipsInPlace()
|
|
{
|
|
var panel = new MossTankPanel(new FakeHost(new FakeAutomation()));
|
|
int index = panel.AdvancedOptionNames.ToList().IndexOf("EnableCombat");
|
|
Assert.True(index >= 0);
|
|
bool before = panel.CombatEnabled;
|
|
|
|
panel.ClickAdvancedOptionValue(index);
|
|
|
|
Assert.Equal(!before, panel.CombatEnabled);
|
|
Assert.Equal(panel.CombatEnabled ? "True" : "False", panel.AdvancedOptionValueColumn[index]);
|
|
|
|
panel.ClickAdvancedOptionValue(index);
|
|
Assert.Equal(before, panel.CombatEnabled);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Round D item 2: clicking a tEnum row's VALUE cell cycles to the
|
|
/// NEXT row of VTank's own SettingsEnumInfo table (UseArcs: 1=No,
|
|
/// 2=At Range, 3=Yes), showing the LABEL rather than the raw stored
|
|
/// integer, and wraps past the last entry back to the first.
|
|
/// </summary>
|
|
[Fact]
|
|
public void ClickAdvancedOptionValue_OnEnumRow_CyclesToTheNextLabelAndWraps()
|
|
{
|
|
var panel = new MossTankPanel(new FakeHost(new FakeAutomation()));
|
|
int index = panel.AdvancedOptionNames.ToList().IndexOf("UseArcs");
|
|
Assert.True(index >= 0);
|
|
|
|
panel.SelectAdvancedOption(index);
|
|
panel.SubmitAdvancedOption("1");
|
|
Assert.Equal("No", panel.AdvancedOptionValueColumn[index]);
|
|
|
|
panel.ClickAdvancedOptionValue(index);
|
|
Assert.Equal("At Range", panel.AdvancedOptionValueColumn[index]);
|
|
|
|
panel.ClickAdvancedOptionValue(index);
|
|
Assert.Equal("Yes", panel.AdvancedOptionValueColumn[index]);
|
|
|
|
panel.ClickAdvancedOptionValue(index);
|
|
Assert.Equal("No", panel.AdvancedOptionValueColumn[index]);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Round D item 2: clicking an int-typed row's VALUE cell (a type the
|
|
/// interaction model says stays edit-field-driven) behaves exactly
|
|
/// like the name-column click always has — selects the row and loads
|
|
/// its current value into the edit field for typing.
|
|
/// </summary>
|
|
[Fact]
|
|
public void ClickAdvancedOptionValue_OnNumericRow_SelectsAndLoadsTheEditField()
|
|
{
|
|
var panel = new MossTankPanel(new FakeHost(new FakeAutomation()));
|
|
int index = panel.AdvancedOptionNames.ToList().IndexOf("SpellDiffExcessThreshold-Hunt");
|
|
Assert.True(index >= 0);
|
|
|
|
panel.ClickAdvancedOptionValue(index);
|
|
|
|
Assert.Equal(index, panel.SelectedAdvancedOptionIndex);
|
|
Assert.Equal(panel.AdvancedOptionValueColumn[index], panel.AdvancedOptionValueDraft);
|
|
}
|
|
|
|
/// <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]);
|
|
}
|
|
|
|
/// <summary>
|
|
/// D-2 (round E architecture re-check): the combo used to show
|
|
/// Enum.GetNames' bare C# identifiers ("PrimaryPortalRecall",
|
|
/// "ParadoxTouchedOlthoiInfestedAreaRecall", ...) — RouteRecallNames/
|
|
/// SelectedRouteRecall must show VTank's real recall names (the same
|
|
/// RecallDisplayName table AddRouteRecallCore already uses), in
|
|
/// VTank's own combo order, and SelectRouteRecall must parse THAT
|
|
/// display string back rather than the enum identifier.
|
|
/// </summary>
|
|
[Fact]
|
|
public void RouteRecallComboShowsVtankNamesNotEnumIdentifiers()
|
|
{
|
|
var automation = new FakeAutomation { NavigationSnapshot = NavigationAt(0f) };
|
|
var panel = new MossTankPanel(new FakeHost(automation));
|
|
|
|
Assert.Equal(
|
|
[
|
|
"Primary Portal Recall", "Secondary Portal Recall", "Lifestone Recall",
|
|
"Lifestone Sending", "Portal Recall", "Recall Aphus Lassel",
|
|
"Recall the Sanctuary", "Recall to the Singularity Caul",
|
|
"Glenden Wood Recall", "Aerlinthe Recall", "Mount Lethe Recall",
|
|
"Ulgrim's Recall", "Bur Recall",
|
|
"Paradox-touched Olthoi Infested Area Recall",
|
|
"Call of the Mhoire Forge", "Colosseum Recall", "Facility Hub Recall",
|
|
"Gear Knight Invasion Area Camp Recall", "Lost City of Neftet Recall",
|
|
"Return to the Keep", "Rynthid Recall", "Viridian Rise Recall",
|
|
"Viridian Rise Great Tree Recall", "Celestial Hand Stronghold Recall",
|
|
"Radiant Blood Stronghold Recall", "Eldrytch Web Stronghold Recall",
|
|
"Marketplace Recall",
|
|
],
|
|
panel.RouteRecallNames);
|
|
|
|
// Default selection reads as a real name, not "PrimaryPortalRecall".
|
|
Assert.Equal("Primary Portal Recall", panel.SelectedRouteRecall);
|
|
|
|
panel.SelectRouteRecall("Lifestone Recall");
|
|
Assert.Equal("Lifestone Recall", panel.SelectedRouteRecall);
|
|
|
|
panel.AddRouteRecall();
|
|
Assert.Contains("Lifestone Recall", panel.RouteNotice, StringComparison.Ordinal);
|
|
Assert.Contains("Lifestone Recall", Assert.Single(panel.RouteWaypointTextColumn));
|
|
}
|
|
|
|
[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;
|
|
}
|
|
}
|
|
}
|