diff --git a/src/AcDream.Plugins.MossTank/MossTankPanel.cs b/src/AcDream.Plugins.MossTank/MossTankPanel.cs index 57106108..f6b20534 100644 --- a/src/AcDream.Plugins.MossTank/MossTankPanel.cs +++ b/src/AcDream.Plugins.MossTank/MossTankPanel.cs @@ -189,6 +189,14 @@ internal sealed partial class MossTankPanel MonsterDamageType.None, ]; private IReadOnlyList _itemRows = Array.Empty(); + // Fix round C item F12: the same SortedCombatItemNames() pass that + // builds _itemRows (with the " [no buffs]" suffix appended where it + // applies) also stores the raw, undecorated names here — the ONE + // place that decoration is added. ItemHandsColumn used to re-derive + // the base name by stripping the suffix back off each display row + // (BaseItemName), a second, opposite-direction definition of the same + // fact that could drift from the first if the suffix ever changed. + private IReadOnlyList _itemBaseNames = Array.Empty(); private IReadOnlyList _consumableRows = Array.Empty(); private int _selectedItemRow; private int _selectedConsumableRow; @@ -203,6 +211,14 @@ internal sealed partial class MossTankPanel // adaptation" note) rather than inventing new profile-wide behavior. private readonly Dictionary _itemHandedness = new(StringComparer.Ordinal); + // 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. Materialized once + // in RefreshItemEditors, refreshed by every real mutator that can + // change a row's handedness or the roster itself (see + // CycleItemHandsAtCore, DeleteItemRowAtCore, and every other + // RefreshItemEditors call site). + private IReadOnlyList _itemHandsColumn = Array.Empty(); private static readonly string[] HandednessCycle = ["Auto", "1-Handed", "2-Handed"]; private IReadOnlyList _excludedComponentRows = Array.Empty(); @@ -688,9 +704,7 @@ internal sealed partial class MossTankPanel // §1 "Tab: Items"; PluginCore.cs:8529-8562 — col 0 click deletes, col 1 // click cycles handedness) ────────────────────────────────────────── public IReadOnlyList ItemNameColumn => _itemRows; - public IReadOnlyList ItemHandsColumn => _itemRows - .Select(row => HandednessCycle[HandednessIndex(BaseItemName(row))]) - .ToArray(); + public IReadOnlyList ItemHandsColumn => _itemHandsColumn; public Action DeleteItemRowAt => DeleteItemRowAtCore; public Action CycleItemHandsAt => CycleItemHandsAtCore; @@ -1799,11 +1813,16 @@ internal sealed partial class MossTankPanel private void RefreshItemEditors() { RefreshConsumableCategories(); - _itemRows = SortedCombatItemNames() + string[] baseNames = SortedCombatItemNames(); + _itemBaseNames = baseNames; + _itemRows = baseNames .Select(name => _noBuffItemNames.Contains(name) ? name + " [no buffs]" : name) .ToArray(); + _itemHandsColumn = baseNames + .Select(name => HandednessCycle[HandednessIndex(name)]) + .ToArray(); _consumableRows = _combatSettings.ConsumableNames .OrderBy(static name => name, StringComparer.Ordinal) .ToArray(); @@ -1822,14 +1841,6 @@ internal sealed partial class MossTankPanel _excludedComponentRows.Count); } - private static string BaseItemName(string displayRow) - { - const string suffix = " [no buffs]"; - return displayRow.EndsWith(suffix, StringComparison.Ordinal) - ? displayRow[..^suffix.Length] - : displayRow; - } - private int HandednessIndex(string name) => _itemHandedness.TryGetValue(name, out int value) ? value : 0; @@ -1849,11 +1860,15 @@ internal sealed partial class MossTankPanel private void CycleItemHandsAtCore(int row) { - string[] names = SortedCombatItemNames(); - if ((uint)row >= (uint)names.Length) + if ((uint)row >= (uint)_itemBaseNames.Count) return; - string name = names[row]; + string name = _itemBaseNames[row]; _itemHandedness[name] = (HandednessIndex(name) + 1) % HandednessCycle.Length; + // Fix round C item F7: ItemHandsColumn is now materialized in + // RefreshItemEditors, not recomputed on every read — this real + // mutator (the grid's own "click cycles handedness" action) must + // refresh it so the next read reflects the new cycle position. + RefreshItemEditors(); } private const string ExcludedComponentDelimiter = "; "; diff --git a/tests/AcDream.Plugins.MossTank.Tests/MossTankPanelTests.cs b/tests/AcDream.Plugins.MossTank.Tests/MossTankPanelTests.cs index 1327f616..4a6e4874 100644 --- a/tests/AcDream.Plugins.MossTank.Tests/MossTankPanelTests.cs +++ b/tests/AcDream.Plugins.MossTank.Tests/MossTankPanelTests.cs @@ -943,6 +943,39 @@ public sealed class MossTankPanelTests 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 afterCycle = panel.ItemHandsColumn; + Assert.Equal("1-Handed", afterCycle[0]); + Assert.Same(afterCycle, panel.ItemHandsColumn); + } + [Fact] public void ConsumablesLeftListRowClickRemovesTheRowDirectly() {