diff --git a/src/AcDream.Plugins.MossTank/MossTankPanel.cs b/src/AcDream.Plugins.MossTank/MossTankPanel.cs index b7fe023dc..deed0f26e 100644 --- a/src/AcDream.Plugins.MossTank/MossTankPanel.cs +++ b/src/AcDream.Plugins.MossTank/MossTankPanel.cs @@ -117,6 +117,37 @@ internal sealed partial class MossTankPanel private string _profileLifecycleNotice = "Macro settings are stored by character."; private string _monsterExpressionDraft = string.Empty; private string _monsterEditorNotice = "Add a monster name or expression, or select one in the world."; + // Fix round B item 12: the Monsters grid's 23 columns, materialized once + // per RefreshMonsterEditor (called at every real mutation point below) — + // not once per row per frame in each column's own getter. The 14 flag + // columns previously re-ran a LINQ .Select(...).ToArray() over + // _combatSettings.Rules on every draw; Weapon/Offhand additionally + // called ItemDisplayName (which itself calls + // IAutomation.Items.CaptureOwnedItems(), a live inventory snapshot) once + // per row per frame. + private IReadOnlyList _monsterFesterColumn = Array.Empty(); + private IReadOnlyList _monsterBroadsideColumn = Array.Empty(); + private IReadOnlyList _monsterGravityWellColumn = Array.Empty(); + private IReadOnlyList _monsterImperilColumn = Array.Empty(); + private IReadOnlyList _monsterYieldColumn = Array.Empty(); + private IReadOnlyList _monsterVulnerabilityColumn = Array.Empty(); + private IReadOnlyList _monsterAttackColumn = Array.Empty(); + private IReadOnlyList _monsterRingColumn = Array.Empty(); + private IReadOnlyList _monsterStreakColumn = Array.Empty(); + private IReadOnlyList _monsterWeakeningColumn = Array.Empty(); + private IReadOnlyList _monsterFesteringColumn = Array.Empty(); + private IReadOnlyList _monsterCorruptionColumn = Array.Empty(); + private IReadOnlyList _monsterDestructiveColumn = Array.Empty(); + private IReadOnlyList _monsterCorrosionColumn = Array.Empty(); + private IReadOnlyList _monsterNameColumn = Array.Empty(); + private IReadOnlyList _monsterPriorityColumn = Array.Empty(); + private IReadOnlyList _monsterDamageColumn = Array.Empty(); + private IReadOnlyList _monsterExtraVulnColumn = Array.Empty(); + private IReadOnlyList _monsterWeaponColumn = Array.Empty(); + private IReadOnlyList _monsterOffhandColumn = Array.Empty(); + private IReadOnlyList _monsterPetDamageColumn = Array.Empty(); + private IReadOnlyList _monsterMoveUpIconsColumn = Array.Empty(); + private IReadOnlyList _monsterMoveDownIconsColumn = Array.Empty(); // VVS's own HudList always has a "selected" backing int even though // VTank's Monsters grid never reads it for anything (every real cell in // that row is an action target, not a select-then-edit target — see @@ -174,6 +205,12 @@ internal sealed partial class MossTankPanel private static readonly string[] HandednessCycle = ["Auto", "1-Handed", "2-Handed"]; private IReadOnlyList _excludedComponentRows = Array.Empty(); + // Fix round B item 12: resolved once per RefreshItemEditors (a real + // state-change event), not once per row per frame — the old + // ExcludedComponentIcons getter called ResolveComponentIcon (which + // itself calls IAutomation.Items.CaptureOwnedItems(), a live inventory + // snapshot) for every row on every draw. + private IReadOnlyList _excludedComponentIcons = Array.Empty(); private int _selectedExcludedComponentRow; private bool _buffPickerVisible; private bool _buffPickerForBlacklist; @@ -202,6 +239,9 @@ internal sealed partial class MossTankPanel private string _lootEditorNotice = "Add a rule or select one to edit."; private string _lootProfileNameDraft = string.Empty; private IReadOnlyList _routeRows = Array.Empty(); + // Fix round B item 12: materialized in RefreshRouteEditor, not once per + // frame in the RouteWaypointCountColumn getter. + private IReadOnlyList _routeWaypointCounts = Array.Empty(); private int _selectedRouteWaypoint; private string _routeProfileNameDraft = string.Empty; private string _routeNotice = "Add the current position or a selected object."; @@ -210,6 +250,15 @@ internal sealed partial class MossTankPanel private RouteRecallKind _routeRecallKind = RouteRecallKind.PrimaryPortal; private RouteInsertMode _routeInsertMode = RouteInsertMode.AddToEnd; private IReadOnlyList _metaRows = Array.Empty(); + // Fix round B item 12: the Meta grid's 6 columns, materialized once per + // RefreshMetaEditor (already called at every real mutation point) — + // not once per row per frame in each column's own getter. + private IReadOnlyList _metaDeleteColumn = Array.Empty(); + private IReadOnlyList _metaMoveUpIconsColumn = Array.Empty(); + private IReadOnlyList _metaMoveDownIconsColumn = Array.Empty(); + private IReadOnlyList _metaStateColumn = Array.Empty(); + private IReadOnlyList _metaConditionColumn = Array.Empty(); + private IReadOnlyList _metaActionColumn = Array.Empty(); private int _selectedMetaRule; private string _metaProfileNameDraft = string.Empty; private string _metaStateDraft = MetaEngine.DefaultState; @@ -325,6 +374,7 @@ internal sealed partial class MossTankPanel _initialized = true; ApplyPersistedOptionOverrides(); EnsureDefaultMonsterRule(); + RefreshMonsterEditor(); RefreshItemEditors(); RefreshLootEditor(); RefreshRouteEditor(); @@ -597,9 +647,7 @@ internal sealed partial class MossTankPanel // Selected" requires a selected spell-component-class object, // per-cell click deletes) ──────────────────────────────────────────── public IReadOnlyList ExcludedComponentRows => _excludedComponentRows; - public IReadOnlyList ExcludedComponentIcons => _excludedComponentRows - .Select(ResolveComponentIcon) - .ToArray(); + public IReadOnlyList ExcludedComponentIcons => _excludedComponentIcons; public int SelectedExcludedComponentIndex => _selectedExcludedComponentRow; public Action SelectExcludedComponentRow => index => _selectedExcludedComponentRow = ClampRow(index, _excludedComponentRows.Count); @@ -844,10 +892,7 @@ internal sealed partial class MossTankPanel // (docs/research/vtank-kb/08-ui-views.md §1 "Tab: Route"; // PluginCore.cs:3576-3599 — any cell click deletes the waypoint). public IReadOnlyList RouteWaypointTextColumn => _routeRows; - public IReadOnlyList RouteWaypointCountColumn => Enumerable - .Range(1, _routeRows.Count) - .Select(static n => n.ToString(CultureInfo.InvariantCulture)) - .ToArray(); + public IReadOnlyList RouteWaypointCountColumn => _routeWaypointCounts; public Action DeleteRouteWaypointAt => DeleteRouteWaypointAtCore; public Action SelectNearestRouteWaypoint => SelectNearestRouteWaypointCore; // VTank's own cmbNavType captions read Circular/Linear/Follow/Once @@ -1017,18 +1062,12 @@ internal sealed partial class MossTankPanel // Monsters/Route grids), and "X" is plain ASCII the default retail // font already renders — see NoButtonAnywhereUsesTheUnrenderableArrowGlyphs // for why an unconfirmed glyph id is worse than a text fallback. ── - public IReadOnlyList MetaDeleteColumn => - Enumerable.Repeat("X", _metaProfile.Rules.Count).ToArray(); - public IReadOnlyList MetaMoveUpIcons => - Enumerable.Repeat(0x060028FCu, _metaProfile.Rules.Count).ToArray(); - public IReadOnlyList MetaMoveDownIcons => - Enumerable.Repeat(0x060028FDu, _metaProfile.Rules.Count).ToArray(); - public IReadOnlyList MetaStateColumn => - _metaProfile.Rules.Select(static rule => rule.State).ToArray(); - public IReadOnlyList MetaConditionColumn => - _metaProfile.Rules.Select(static rule => DescribeMetaCondition(rule.Condition)).ToArray(); - public IReadOnlyList MetaActionColumn => - _metaProfile.Rules.Select(static rule => DescribeMetaAction(rule.Action)).ToArray(); + public IReadOnlyList MetaDeleteColumn => _metaDeleteColumn; + public IReadOnlyList MetaMoveUpIcons => _metaMoveUpIconsColumn; + public IReadOnlyList MetaMoveDownIcons => _metaMoveDownIconsColumn; + public IReadOnlyList MetaStateColumn => _metaStateColumn; + public IReadOnlyList MetaConditionColumn => _metaConditionColumn; + public IReadOnlyList MetaActionColumn => _metaActionColumn; public Action DeleteMetaRuleAt => row => { SelectMetaRuleCore(row); @@ -1226,12 +1265,12 @@ internal sealed partial class MossTankPanel // ── the 14 check columns (decompile cases 0-13; order F B G I Y V A R S // WC FC Cp DC Cs matches MonsterActionFlags's own declared bit order) ── - public IReadOnlyList MonsterFesterColumn => MonsterFlagColumn(MonsterActionFlags.Fester); - public IReadOnlyList MonsterBroadsideColumn => MonsterFlagColumn(MonsterActionFlags.Broadside); - public IReadOnlyList MonsterGravityWellColumn => MonsterFlagColumn(MonsterActionFlags.GravityWell); - public IReadOnlyList MonsterImperilColumn => MonsterFlagColumn(MonsterActionFlags.Imperil); - public IReadOnlyList MonsterYieldColumn => MonsterFlagColumn(MonsterActionFlags.Yield); - public IReadOnlyList MonsterVulnerabilityColumn => MonsterFlagColumn(MonsterActionFlags.Vulnerability); + public IReadOnlyList MonsterFesterColumn => _monsterFesterColumn; + public IReadOnlyList MonsterBroadsideColumn => _monsterBroadsideColumn; + public IReadOnlyList MonsterGravityWellColumn => _monsterGravityWellColumn; + public IReadOnlyList MonsterImperilColumn => _monsterImperilColumn; + public IReadOnlyList MonsterYieldColumn => _monsterYieldColumn; + public IReadOnlyList MonsterVulnerabilityColumn => _monsterVulnerabilityColumn; // Case 6 ("A") assigns inverted in VTank's own obfuscated field // (a10.t = !checkbox), but that inversion is purely an artifact of // VTank's internal storage — the CHECKBOX ITSELF still shows "checked @@ -1239,14 +1278,14 @@ internal sealed partial class MossTankPanel // the field back). Our MonsterActionFlags.Attack is already declared in // the un-inverted, on-screen sense, so no inversion is needed here: this // column reads/writes Attack directly and shows exactly what VTank shows. - public IReadOnlyList MonsterAttackColumn => MonsterFlagColumn(MonsterActionFlags.Attack); - public IReadOnlyList MonsterRingColumn => MonsterFlagColumn(MonsterActionFlags.Ring); - public IReadOnlyList MonsterStreakColumn => MonsterFlagColumn(MonsterActionFlags.Streak); - public IReadOnlyList MonsterWeakeningColumn => MonsterFlagColumn(MonsterActionFlags.WeakeningCurse); - public IReadOnlyList MonsterFesteringColumn => MonsterFlagColumn(MonsterActionFlags.FesteringCurse); - public IReadOnlyList MonsterCorruptionColumn => MonsterFlagColumn(MonsterActionFlags.Corruption); - public IReadOnlyList MonsterDestructiveColumn => MonsterFlagColumn(MonsterActionFlags.DestructiveCurse); - public IReadOnlyList MonsterCorrosionColumn => MonsterFlagColumn(MonsterActionFlags.Corrosion); + public IReadOnlyList MonsterAttackColumn => _monsterAttackColumn; + public IReadOnlyList MonsterRingColumn => _monsterRingColumn; + public IReadOnlyList MonsterStreakColumn => _monsterStreakColumn; + public IReadOnlyList MonsterWeakeningColumn => _monsterWeakeningColumn; + public IReadOnlyList MonsterFesteringColumn => _monsterFesteringColumn; + public IReadOnlyList MonsterCorruptionColumn => _monsterCorruptionColumn; + public IReadOnlyList MonsterDestructiveColumn => _monsterDestructiveColumn; + public IReadOnlyList MonsterCorrosionColumn => _monsterCorrosionColumn; public Action ToggleMonsterFesterAt => row => ToggleMonsterFlagAt(row, MonsterActionFlags.Fester); public Action ToggleMonsterBroadsideAt => row => ToggleMonsterFlagAt(row, MonsterActionFlags.Broadside); @@ -1264,26 +1303,13 @@ internal sealed partial class MossTankPanel public Action ToggleMonsterCorrosionAt => row => ToggleMonsterFlagAt(row, MonsterActionFlags.Corrosion); // ── the 7 text columns (decompile cases 14-20) ────────────────────────── - public IReadOnlyList MonsterNameColumn => - _combatSettings.Rules.Select(static r => r.Expression).ToArray(); - public IReadOnlyList MonsterPriorityColumn => - _combatSettings.Rules.Select( - static r => r.Actions.BoundedPriority.ToString(CultureInfo.InvariantCulture)) - .ToArray(); - public IReadOnlyList MonsterDamageColumn => - _combatSettings.Rules.Select(static r => DamageTypeDisplay(r.Actions.DamageType)).ToArray(); - public IReadOnlyList MonsterExtraVulnColumn => - _combatSettings.Rules.Select(static r => DamageTypeDisplay(r.Actions.ExtraVulnerability)).ToArray(); - public IReadOnlyList MonsterWeaponColumn => - _combatSettings.Rules.Select( - r => ItemDisplayName(r.Actions.WeaponObjectId, r.Actions.WeaponName)) - .ToArray(); - public IReadOnlyList MonsterOffhandColumn => - _combatSettings.Rules.Select( - r => ItemDisplayName(r.Actions.OffhandObjectId, r.Actions.OffhandName)) - .ToArray(); - public IReadOnlyList MonsterPetDamageColumn => - _combatSettings.Rules.Select(static r => DamageTypeDisplay(r.Actions.PetDamageType)).ToArray(); + public IReadOnlyList MonsterNameColumn => _monsterNameColumn; + public IReadOnlyList MonsterPriorityColumn => _monsterPriorityColumn; + public IReadOnlyList MonsterDamageColumn => _monsterDamageColumn; + public IReadOnlyList MonsterExtraVulnColumn => _monsterExtraVulnColumn; + public IReadOnlyList MonsterWeaponColumn => _monsterWeaponColumn; + public IReadOnlyList MonsterOffhandColumn => _monsterOffhandColumn; + public IReadOnlyList MonsterPetDamageColumn => _monsterPetDamageColumn; // Case 14: clicking the Name cell of any non-DEFAULT row deletes it // (PluginCore.cs:7948-7955's `if (A_1 != 0) { ...; b(A_1); }`, where @@ -1325,10 +1351,8 @@ internal sealed partial class MossTankPanel // ── the 2 icon columns (decompile cases 21/22 — move up/down; DAT ids // 0x060028FC/0x060028FD, docs/plans/2026-09-07-campaign-vt-slice7-tabs.md // S7.3) ────────────────────────────────────────────────────────────── - public IReadOnlyList MonsterMoveUpIcons => - Enumerable.Repeat(0x060028FCu, _combatSettings.Rules.Count).ToArray(); - public IReadOnlyList MonsterMoveDownIcons => - Enumerable.Repeat(0x060028FDu, _combatSettings.Rules.Count).ToArray(); + public IReadOnlyList MonsterMoveUpIcons => _monsterMoveUpIconsColumn; + public IReadOnlyList MonsterMoveDownIcons => _monsterMoveDownIconsColumn; public Action MoveMonsterRuleUpAt => row => MoveMonsterRuleAtCore(row, -1); public Action MoveMonsterRuleDownAt => row => MoveMonsterRuleAtCore(row, 1); @@ -1735,6 +1759,10 @@ internal sealed partial class MossTankPanel _consumableRows.Count); _excludedComponentRows = ParseExcludedComponents( _buffSettings.BlacklistedSpellComponents); + var excludedIcons = new uint[_excludedComponentRows.Count]; + for (int i = 0; i < excludedIcons.Length; i++) + excludedIcons[i] = ResolveComponentIcon(_excludedComponentRows[i]); + _excludedComponentIcons = excludedIcons; _selectedExcludedComponentRow = ClampRow( _selectedExcludedComponentRow, _excludedComponentRows.Count); @@ -2143,6 +2171,10 @@ internal sealed partial class MossTankPanel $"{(index == active && _navigationSettings.Enabled ? "<<" : " ")} " + waypoint.DisplayText) .ToArray(); + var counts = new string[_routeRows.Count]; + for (int i = 0; i < counts.Length; i++) + counts[i] = (i + 1).ToString(CultureInfo.InvariantCulture); + _routeWaypointCounts = counts; } private void AddRoutePointCore() @@ -2502,8 +2534,95 @@ internal sealed partial class MossTankPanel _combatSettings.Rules.Add(new MonsterRule("DEFAULT", 0)); } - private IReadOnlyList MonsterFlagColumn(MonsterActionFlags flag) => - _combatSettings.Rules.Select(r => (r.Actions.Flags & flag) != 0).ToArray(); + /// + /// Fix round B item 12: materializes every Monsters-grid column + /// (14 flags + 7 text + 2 move icons = 23) from + /// 's Rules in one pass. Called from + /// every real mutation point (Add/Delete/Move/UpdateMonsterActionsAt) + /// plus panel construction and profile reload — never from a column's + /// own getter, which previously re-ran this same work (plus, for + /// Weapon/Offhand, a live CaptureOwnedItems() scan) on every + /// single frame. + /// + private void RefreshMonsterEditor() + { + int count = _combatSettings.Rules.Count; + var fester = new bool[count]; + var broadside = new bool[count]; + var gravityWell = new bool[count]; + var imperil = new bool[count]; + var yield = new bool[count]; + var vulnerability = new bool[count]; + var attack = new bool[count]; + var ring = new bool[count]; + var streak = new bool[count]; + var weakening = new bool[count]; + var festering = new bool[count]; + var corruption = new bool[count]; + var destructive = new bool[count]; + var corrosion = new bool[count]; + var names = new string[count]; + var priorities = new string[count]; + var damage = new string[count]; + var extraVuln = new string[count]; + var weapon = new string[count]; + var offhand = new string[count]; + var petDamage = new string[count]; + var moveUpIcons = new uint[count]; + var moveDownIcons = new uint[count]; + for (int i = 0; i < count; i++) + { + MonsterRule rule = _combatSettings.Rules[i]; + MonsterRuleActions actions = rule.Actions; + MonsterActionFlags flags = actions.Flags; + fester[i] = (flags & MonsterActionFlags.Fester) != 0; + broadside[i] = (flags & MonsterActionFlags.Broadside) != 0; + gravityWell[i] = (flags & MonsterActionFlags.GravityWell) != 0; + imperil[i] = (flags & MonsterActionFlags.Imperil) != 0; + yield[i] = (flags & MonsterActionFlags.Yield) != 0; + vulnerability[i] = (flags & MonsterActionFlags.Vulnerability) != 0; + attack[i] = (flags & MonsterActionFlags.Attack) != 0; + ring[i] = (flags & MonsterActionFlags.Ring) != 0; + streak[i] = (flags & MonsterActionFlags.Streak) != 0; + weakening[i] = (flags & MonsterActionFlags.WeakeningCurse) != 0; + festering[i] = (flags & MonsterActionFlags.FesteringCurse) != 0; + corruption[i] = (flags & MonsterActionFlags.Corruption) != 0; + destructive[i] = (flags & MonsterActionFlags.DestructiveCurse) != 0; + corrosion[i] = (flags & MonsterActionFlags.Corrosion) != 0; + names[i] = rule.Expression; + priorities[i] = actions.BoundedPriority.ToString(CultureInfo.InvariantCulture); + damage[i] = DamageTypeDisplay(actions.DamageType); + extraVuln[i] = DamageTypeDisplay(actions.ExtraVulnerability); + weapon[i] = ItemDisplayName(actions.WeaponObjectId, actions.WeaponName); + offhand[i] = ItemDisplayName(actions.OffhandObjectId, actions.OffhandName); + petDamage[i] = DamageTypeDisplay(actions.PetDamageType); + moveUpIcons[i] = 0x060028FCu; + moveDownIcons[i] = 0x060028FDu; + } + _monsterFesterColumn = fester; + _monsterBroadsideColumn = broadside; + _monsterGravityWellColumn = gravityWell; + _monsterImperilColumn = imperil; + _monsterYieldColumn = yield; + _monsterVulnerabilityColumn = vulnerability; + _monsterAttackColumn = attack; + _monsterRingColumn = ring; + _monsterStreakColumn = streak; + _monsterWeakeningColumn = weakening; + _monsterFesteringColumn = festering; + _monsterCorruptionColumn = corruption; + _monsterDestructiveColumn = destructive; + _monsterCorrosionColumn = corrosion; + _monsterNameColumn = names; + _monsterPriorityColumn = priorities; + _monsterDamageColumn = damage; + _monsterExtraVulnColumn = extraVuln; + _monsterWeaponColumn = weapon; + _monsterOffhandColumn = offhand; + _monsterPetDamageColumn = petDamage; + _monsterMoveUpIconsColumn = moveUpIcons; + _monsterMoveDownIconsColumn = moveDownIcons; + } private void ToggleMonsterFlagAt(int row, MonsterActionFlags flag) => UpdateMonsterActionsAt(row, actions => actions with { Flags = actions.Flags ^ flag }); @@ -2515,6 +2634,7 @@ internal sealed partial class MossTankPanel var rule = new MonsterRule(expression, new MonsterRuleActions()); _combatSettings.Rules.Add(rule); _monsterEditorNotice = $"Added {expression}."; + RefreshMonsterEditor(); SaveProfile(); } catch (FormatException error) @@ -2575,6 +2695,7 @@ internal sealed partial class MossTankPanel _combatSettings.Rules.RemoveAt(row); _monsterEditorNotice = $"Removed {rule.Expression}."; EnsureDefaultMonsterRule(); + RefreshMonsterEditor(); SaveProfile(); } @@ -2690,6 +2811,7 @@ internal sealed partial class MossTankPanel _combatSettings.Rules.RemoveAt(row); _combatSettings.Rules.Insert(destination, current); _monsterEditorNotice = $"Moved {current.Expression}."; + RefreshMonsterEditor(); SaveProfile(); } @@ -2700,6 +2822,7 @@ internal sealed partial class MossTankPanel return; MonsterRule current = _combatSettings.Rules[row]; _combatSettings.Rules[row] = new MonsterRule(current.Expression, update(current.Actions)); + RefreshMonsterEditor(); SaveProfile(); } @@ -2807,9 +2930,34 @@ internal sealed partial class MossTankPanel private void RefreshMetaEditor() { - _metaRows = _metaProfile.Rules.Select(static rule => - $"{rule.State,-16} {DescribeMetaCondition(rule.Condition),-34} " - + DescribeMetaAction(rule.Action)).ToArray(); + int count = _metaProfile.Rules.Count; + var rows = new string[count]; + var deleteColumn = new string[count]; + var moveUpIcons = new uint[count]; + var moveDownIcons = new uint[count]; + var stateColumn = new string[count]; + var conditionColumn = new string[count]; + var actionColumn = new string[count]; + for (int i = 0; i < count; i++) + { + MetaRule rule = _metaProfile.Rules[i]; + string conditionText = DescribeMetaCondition(rule.Condition); + string actionText = DescribeMetaAction(rule.Action); + rows[i] = $"{rule.State,-16} {conditionText,-34} {actionText}"; + deleteColumn[i] = "X"; + moveUpIcons[i] = 0x060028FCu; + moveDownIcons[i] = 0x060028FDu; + stateColumn[i] = rule.State; + conditionColumn[i] = conditionText; + actionColumn[i] = actionText; + } + _metaRows = rows; + _metaDeleteColumn = deleteColumn; + _metaMoveUpIconsColumn = moveUpIcons; + _metaMoveDownIconsColumn = moveDownIcons; + _metaStateColumn = stateColumn; + _metaConditionColumn = conditionColumn; + _metaActionColumn = actionColumn; _selectedMetaRule = ClampRow(_selectedMetaRule, _metaProfile.Rules.Count); if (SelectedMetaRule is not MetaRule selected) return; @@ -4111,6 +4259,7 @@ internal sealed partial class MossTankPanel _coverageSpellSnapshot = null; _coverageRefreshRemaining = 0d; EnsureDefaultMonsterRule(); + RefreshMonsterEditor(); RefreshItemEditors(); RefreshLootEditor(); RefreshRouteEditor(); diff --git a/tests/AcDream.Plugins.MossTank.Tests/MossTankPanelTests.cs b/tests/AcDream.Plugins.MossTank.Tests/MossTankPanelTests.cs index 1fa3e2911..09901d5a6 100644 --- a/tests/AcDream.Plugins.MossTank.Tests/MossTankPanelTests.cs +++ b/tests/AcDream.Plugins.MossTank.Tests/MossTankPanelTests.cs @@ -1002,6 +1002,34 @@ public sealed class MossTankPanelTests 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 BuffPickerAddsToTheRequestedListAndAnyCellClickDeletes() { @@ -2087,6 +2115,43 @@ public sealed class MossTankPanelTests 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 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() { @@ -2230,6 +2295,33 @@ public sealed class MossTankPanelTests 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() { @@ -2992,7 +3084,15 @@ public sealed class MossTankPanelTests public int BusyReferences { get; set; } public PluginNavigationSnapshot NavigationSnapshot { get; set; } public PluginNavigationSnapshot Snapshot => NavigationSnapshot; - public IReadOnlyList CaptureOwnedItems() => ItemEntries; + // 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 CaptureOwnedItems() + { + CaptureOwnedItemsCallCount++; + return ItemEntries; + } public IReadOnlyList CaptureObjects() => WorldObjects; bool IWorldObjectAutomation.TryGet(