From 37055bddcee6659360aa91bd9176d9cd8db206e3 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 7 Sep 2026 15:37:26 +0200 Subject: [PATCH] =?UTF-8?q?fix(vtank):=20slice=207=20fix=20round=20C=20ite?= =?UTF-8?q?ms=20F7+F12=20=E2=80=94=20materialize=20ItemHandsColumn=20once?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ItemHandsColumn ran a fresh .Select(...).ToArray() over _itemRows, PLUS a per-row BaseItemName suffix-strip to recover the undecorated name, on every single retained-UI draw. Folded per item F12: RefreshItemEditors now captures the raw SortedCombatItemNames() result once as _itemBaseNames — the SAME array used both to build the decorated _itemRows (append " [no buffs]" where it applies) and to compute _itemHandsColumn directly, so the " [no buffs]" suffix has exactly one definition (added going forward) instead of two (added in RefreshItemEditors, parsed back off in the old ItemHandsColumn getter). CycleItemHandsAtCore (the grid's own "click cycles handedness" cell action) mutates _itemHandedness but did not call RefreshItemEditors — unlike the Monsters grid's mutators, which all already refresh after mutating. Added the call so the cached column stays correct; also switched it to read the cached _itemBaseNames instead of a second SortedCombatItemNames() call. Mutation named: temporarily restored the old live-recomputing ItemHandsColumn getter (with a local copy of the suffix-stripping helper) and confirmed the new ItemHandsColumnDoesNotReallocateOnEveryReadAndNoBuffSuffixNeverLeaks pin fails (Assert.Same throws — different array instances per read) before restoring the fix. The same test also proves the no-buffs case still resolves handedness correctly through the folded base-name array. MossTank suite 680 -> 681 (one new pin). Full solution build green; App markup/plugin filter 203/203. Co-Authored-By: Claude Fable 5.1 --- src/AcDream.Plugins.MossTank/MossTankPanel.cs | 45 ++++++++++++------- .../MossTankPanelTests.cs | 33 ++++++++++++++ 2 files changed, 63 insertions(+), 15 deletions(-) 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() {