feat(vtank): slice 7 S7.4 — Items/Consumables/Buffs grids and picker

VTank's real Items (2-col name/hands), Consumables (adds the right-hand
"Excluded Scarab Types" icon+text grid), and Buffs (Extra Buff Spells /
Blacklisted Buff Families lists + a shared SelfBuffChoiceView-style
picker popup) tabs are transcribed from docs/research/vtank-kb/
08-ui-views.md §1, replacing the old single-column-list adaptations with
real per-cell grids and VTank's own click-to-delete/cycle semantics
(PluginCore.cs:8529-8562 Items, :7683-7776 Consumables, :7323-7355 Buffs).

Deviations, documented at their own binding site:
- Items' Hands column has no backing wieldable-handedness data anywhere
  in the plugin surface, so handedness is session-local UI state only
  (not persisted across profile save/load) — same "deliberate adaptation"
  shape as S7.3's weapon-roster substitution for Monsters' Weapon/Offhand.
- Consumables' "Add Selected" accepts any selected owned item rather than
  requiring VTank's own SpellComponent object-class check (no classifier
  surface exists for plugins) — the added token is still the item's real
  Name, which SpellComponentPolicy already matches against.
- Buffs' ExtraBuffSpellNames/BlacklistedBuffFamilyNames (BuffPlan.cs) add
  storage + UI only; wiring them into BuffPlan.Build's cast selection is
  real casting-algorithm behavior, out of this UI-parity slice's scope
  (tracked in the slice 7 plan ledger as a real, accepted gap for a
  future Campaign VT behavior slice).

Every new/changed pin (contract control count 167->177, the new
mosstank-buffpicker.xml popup pin, the three new MossTankPanelTests
interaction tests) was shown to fail against a targeted mutation before
being confirmed green. MossTank suite 654 -> 658; App markup/plugin
filter holds 192/192.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-09-07 10:26:13 +02:00
parent 66b070def4
commit f5409530f2
10 changed files with 516 additions and 31 deletions

View file

@ -103,8 +103,9 @@
<!-- MossTank ships as a copy-only plugin, plus its panel markup:
MossTankPlugin resolves mosstank.xml (and, since fix round A moved
the Advanced Options / Loot Editor popups to their own panels,
mosstank-advanced.xml / mosstank-loot-editor.xml too) relative to its
own assembly, so all four files must land in the same plugin
mosstank-advanced.xml / mosstank-loot-editor.xml too, plus Campaign
VT S7.4's mosstank-buffpicker.xml popup) relative to its own
assembly, so all five files must land in the same plugin
directory. -->
<ItemGroup>
<ProjectReference Include="..\AcDream.Plugins.MossTank\AcDream.Plugins.MossTank.csproj">
@ -127,7 +128,7 @@
</MSBuild>
<MakeDir Directories="$(_MossTankDestDir)" />
<Copy
SourceFiles="@(_MossTankPluginBuildTarget);$(MSBuildProjectDirectory)/../AcDream.Plugins.MossTank/mosstank.xml;$(MSBuildProjectDirectory)/../AcDream.Plugins.MossTank/mosstank-advanced.xml;$(MSBuildProjectDirectory)/../AcDream.Plugins.MossTank/mosstank-loot-editor.xml"
SourceFiles="@(_MossTankPluginBuildTarget);$(MSBuildProjectDirectory)/../AcDream.Plugins.MossTank/mosstank.xml;$(MSBuildProjectDirectory)/../AcDream.Plugins.MossTank/mosstank-advanced.xml;$(MSBuildProjectDirectory)/../AcDream.Plugins.MossTank/mosstank-loot-editor.xml;$(MSBuildProjectDirectory)/../AcDream.Plugins.MossTank/mosstank-buffpicker.xml"
DestinationFolder="$(_MossTankDestDir)"
SkipUnchangedFiles="true" />
<WriteLinesToFile
@ -151,7 +152,7 @@
</MSBuild>
<MakeDir Directories="$(_MossTankPublishDestDir)" />
<Copy
SourceFiles="@(_MossTankPluginPublishTarget);$(MSBuildProjectDirectory)/../AcDream.Plugins.MossTank/mosstank.xml;$(MSBuildProjectDirectory)/../AcDream.Plugins.MossTank/mosstank-advanced.xml;$(MSBuildProjectDirectory)/../AcDream.Plugins.MossTank/mosstank-loot-editor.xml"
SourceFiles="@(_MossTankPluginPublishTarget);$(MSBuildProjectDirectory)/../AcDream.Plugins.MossTank/mosstank.xml;$(MSBuildProjectDirectory)/../AcDream.Plugins.MossTank/mosstank-advanced.xml;$(MSBuildProjectDirectory)/../AcDream.Plugins.MossTank/mosstank-loot-editor.xml;$(MSBuildProjectDirectory)/../AcDream.Plugins.MossTank/mosstank-buffpicker.xml"
DestinationFolder="$(_MossTankPublishDestDir)"
SkipUnchangedFiles="true" />
<WriteLinesToFile

View file

@ -28,6 +28,9 @@
<None Update="mosstank-loot-editor.xml">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="mosstank-buffpicker.xml">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<EmbeddedResource Include="VtankCraftRecipes.tsv" />
<EmbeddedResource Include="VtankAmmunitionOptions.tsv" />
<EmbeddedResource Include="VtankDefaultSettings.usd" />

View file

@ -84,6 +84,30 @@ public sealed class BuffSettings
public int BuffWithUntrainedItemSkill { get; set; } = 80;
public int BuffWithUntrainedCreatureSkill { get; set; } = 80;
public int BuffWithUntrainedLifeSkill { get; set; } = 80;
/// <summary>
/// VTank's ExtraBuffSpells table (single-field ExemplarId list,
/// defaultsettings.usd:21-25) — named spell exemplars to cast "best
/// similar" beyond the school-driven picks above. Campaign VT slice 7
/// S7.4 adds the storage and the Buffs-tab UI (list + the
/// SelfBuffChoiceView-style picker) but does NOT wire this set into
/// <see cref="BuffPlan.Build"/>'s cast selection — resolving an
/// exemplar name to a real castable line/tier is genuine casting-
/// algorithm behavior, out of this UI-parity slice's scope (tracked as
/// a real, accepted gap in the slice 7 plan ledger, owned by a future
/// Campaign VT behavior slice).
/// </summary>
public ISet<string> ExtraBuffSpellNames { get; } =
new HashSet<string>(StringComparer.Ordinal);
/// <summary>
/// VTank's AntiExtraBuffSpells table (defaultsettings.usd:1-6) — named
/// buff families to never cast even if the school-driven rules above
/// would otherwise want them. Same storage-only scope note as
/// <see cref="ExtraBuffSpellNames"/>.
/// </summary>
public ISet<string> BlacklistedBuffFamilyNames { get; } =
new HashSet<string>(StringComparer.Ordinal);
}
/// <summary>

View file

@ -160,6 +160,31 @@ internal sealed partial class MossTankPanel
private IReadOnlyList<string> _consumableRows = Array.Empty<string>();
private int _selectedItemRow;
private int _selectedConsumableRow;
// Campaign VT S7.4: VTank's Items-tab Hands column
// (PluginCore.cs:8546-8560, case 1) cycles a real eItemUseSpecifier
// WEAP_* enum persisted per item. MossTank tracks no handedness data at
// all for a registered weapon/wand/shield/pet (CombatItemNames is a bare
// name set) and the plugin surface exposes no wieldable-handedness field
// to derive one from — so this is session-local UI state only (not
// persisted across profile save/load), a documented, accepted gap
// matching the S7.3 precedent (CycleMonsterWeaponAt's own "deliberate
// adaptation" note) rather than inventing new profile-wide behavior.
private readonly Dictionary<string, int> _itemHandedness =
new(StringComparer.Ordinal);
private static readonly string[] HandednessCycle =
["Auto", "1-Handed", "2-Handed"];
private IReadOnlyList<string> _excludedComponentRows = Array.Empty<string>();
private int _selectedExcludedComponentRow;
private bool _buffPickerVisible;
private bool _buffPickerForBlacklist;
private string _buffPickerSearchText = string.Empty;
private int _selectedBuffPickerRow;
// The Buffs-tab lists themselves have no meaningful "selected row" the
// way the picker popup does (VTank's own click-anywhere-deletes model
// never leaves a row highlighted, PluginCore.cs:7323-7355) — these two
// fields exist only because <list selected> is a required binding.
private int _selectedExtraBuffRow;
private int _selectedBlacklistedBuffRow;
private bool _lootEditorVisible;
private bool _advancedOptionsVisible;
private int _selectedAdvancedOption;
@ -487,6 +512,32 @@ internal sealed partial class MossTankPanel
_selectedConsumableRow = ClampRow(index, _consumableRows.Count);
public Action RemoveSelectedItem => RemoveSelectedItemCore;
public Action RemoveSelectedConsumable => RemoveSelectedConsumableCore;
// ── Items tab grid (Campaign VT S7.4: VTank's own 2-column
// clWeaponName/clHandedness list, docs/research/vtank-kb/08-ui-views.md
// §1 "Tab: Items"; PluginCore.cs:8529-8562 — col 0 click deletes, col 1
// click cycles handedness) ──────────────────────────────────────────
public IReadOnlyList<string> ItemNameColumn => _itemRows;
public IReadOnlyList<string> ItemHandsColumn => _itemRows
.Select(row => HandednessCycle[HandednessIndex(BaseItemName(row))])
.ToArray();
public Action<int> DeleteItemRowAt => DeleteItemRowAtCore;
public Action<int> CycleItemHandsAt => CycleItemHandsAtCore;
// ── Consumables tab "Excluded Scarab Types" grid (Campaign VT S7.4:
// VTank's own lstExcludedComponents, docs/research/vtank-kb/
// 08-ui-views.md §1 "Tab: Consumables"; PluginCore.cs:7683-7776 — "Add
// Selected" requires a selected spell-component-class object,
// per-cell click deletes) ────────────────────────────────────────────
public IReadOnlyList<string> ExcludedComponentRows => _excludedComponentRows;
public IReadOnlyList<uint> ExcludedComponentIcons => _excludedComponentRows
.Select(ResolveComponentIcon)
.ToArray();
public int SelectedExcludedComponentIndex => _selectedExcludedComponentRow;
public Action<int> SelectExcludedComponentRow => index =>
_selectedExcludedComponentRow = ClampRow(index, _excludedComponentRows.Count);
public Action<int> DeleteExcludedComponentAt => DeleteExcludedComponentAtCore;
public Action AddSelectedComponent => AddSelectedComponentCore;
public Action ToggleAutoStack => () =>
{
_inventorySettings.AutoStack = !_inventorySettings.AutoStack;
@ -1156,6 +1207,49 @@ internal sealed partial class MossTankPanel
public bool RegenerationEnabled => _buffSettings.BuffRegeneration;
public bool OtherEnabled => _buffSettings.BuffOther;
// ── Buffs tab Extra/Blacklisted lists + picker (Campaign VT S7.4:
// VTank's own lstBuffSpells/lstAntiBuffSpells, docs/research/vtank-kb/
// 08-ui-views.md §1 "Tab: Buffs"; PluginCore.cs:7323-7355 — any cell
// click removes that row; "Add..." opens SelfBuffChoiceView, a
// search-filtered single-column exemplar picker, §1's secondary-view
// table). ExtraBuffSpellNames/BlacklistedBuffFamilyNames
// (BuffPlan.cs) are storage + UI only for this slice — see that
// property's own doc comment for why BuffPlan.Build does not yet
// consult them. ────────────────────────────────────────────────────
public IReadOnlyList<string> ExtraBuffRows => Sorted(_buffSettings.ExtraBuffSpellNames);
public IReadOnlyList<string> BlacklistedBuffFamilyRows =>
Sorted(_buffSettings.BlacklistedBuffFamilyNames);
public int SelectedExtraBuffIndex => _selectedExtraBuffRow;
public int SelectedBlacklistedBuffIndex => _selectedBlacklistedBuffRow;
public Action<int> DeleteExtraBuffAt => row =>
{
_selectedExtraBuffRow = row;
DeleteFromNamedSet(_buffSettings.ExtraBuffSpellNames, row);
};
public Action<int> DeleteBlacklistedBuffFamilyAt => row =>
{
_selectedBlacklistedBuffRow = row;
DeleteFromNamedSet(_buffSettings.BlacklistedBuffFamilyNames, row);
};
public Action ShowExtraBuffPicker => () => ShowBuffPickerCore(forBlacklist: false);
public Action ShowBlacklistedBuffPicker => () => ShowBuffPickerCore(forBlacklist: true);
public bool BuffPickerVisible => _buffPickerVisible;
public string BuffPickerSearchText => _buffPickerSearchText;
public Action<string> SetBuffPickerSearchText => value =>
_buffPickerSearchText = value;
public IReadOnlyList<string> BuffPickerRows => _host.Automation.Spells.KnownSelfBuffs
.Select(static spell => spell.Name)
.Where(name => string.IsNullOrWhiteSpace(_buffPickerSearchText)
|| name.Contains(_buffPickerSearchText, StringComparison.OrdinalIgnoreCase))
.Distinct(StringComparer.Ordinal)
.OrderBy(static name => name, StringComparer.Ordinal)
.ToArray();
public int SelectedBuffPickerIndex => _selectedBuffPickerRow;
public Action<int> SelectBuffPickerRow => index =>
_selectedBuffPickerRow = index;
public Action<int> PickBuffAt => PickBuffAtCore;
public Action HideBuffPicker => () => _buffPickerVisible = false;
public string TargetMethodText =>
$"Target selection: {_combatSettings.SelectionMethod}";
public string TargetLockText =>
@ -1455,6 +1549,145 @@ internal sealed partial class MossTankPanel
_selectedConsumableRow = ClampRow(
_selectedConsumableRow,
_consumableRows.Count);
_excludedComponentRows = ParseExcludedComponents(
_buffSettings.BlacklistedSpellComponents);
_selectedExcludedComponentRow = ClampRow(
_selectedExcludedComponentRow,
_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;
private void DeleteItemRowAtCore(int row)
{
string[] names = _combatSettings.CombatItemNames
.OrderBy(static name => name, StringComparer.Ordinal)
.ToArray();
if ((uint)row >= (uint)names.Length)
return;
string removed = names[row];
_combatSettings.CombatItemNames.Remove(removed);
_noBuffItemNames.Remove(removed);
_itemHandedness.Remove(removed);
_profileNotice = $"Removed {removed}.";
RefreshItemEditors();
SaveProfile();
}
private void CycleItemHandsAtCore(int row)
{
string[] names = _combatSettings.CombatItemNames
.OrderBy(static name => name, StringComparer.Ordinal)
.ToArray();
if ((uint)row >= (uint)names.Length)
return;
string name = names[row];
_itemHandedness[name] = (HandednessIndex(name) + 1) % HandednessCycle.Length;
}
private const string ExcludedComponentDelimiter = "; ";
private static IReadOnlyList<string> ParseExcludedComponents(string setting) =>
string.IsNullOrWhiteSpace(setting)
? Array.Empty<string>()
: setting.Split(
';',
StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries);
private uint ResolveComponentIcon(string name)
{
foreach (PluginInventoryItem item in _host.Automation.Items.CaptureOwnedItems())
{
if (string.Equals(item.Name, name, StringComparison.OrdinalIgnoreCase))
return item.IconId;
}
return 0u;
}
/// <summary>
/// PluginCore.cs:7714-7746 (VTank's "Add Selected"): requires the
/// current selection to resolve to an owned SpellComponent-class
/// object; MossTank's plugin surface has no object-class classifier, so
/// this accepts any selected owned inventory item (matching retail
/// component naming, where a reagent's inventory Name IS the
/// SpellComponentTable Name that SpellComponentPolicy already matches
/// against — see SpellComponentPolicy.cs).
/// </summary>
private void AddSelectedComponentCore()
{
if (!TryGetSelectedInventoryItem(out PluginInventoryItem item))
{
_profileNotice = "Select an owned spell component first.";
return;
}
if (_excludedComponentRows.Contains(item.Name, StringComparer.OrdinalIgnoreCase))
{
_profileNotice = "Blacklist entry already exists.";
return;
}
var updated = new List<string>(_excludedComponentRows) { item.Name };
string joined = string.Join(ExcludedComponentDelimiter, updated);
_buffSettings.BlacklistedSpellComponents = joined;
_combatSettings.BlacklistedSpellComponents = joined;
_profileNotice = $"Added {item.Name}.";
RefreshItemEditors();
SaveProfile();
}
private void DeleteExcludedComponentAtCore(int row)
{
if ((uint)row >= (uint)_excludedComponentRows.Count)
return;
var updated = new List<string>(_excludedComponentRows);
string removed = updated[row];
updated.RemoveAt(row);
string joined = string.Join(ExcludedComponentDelimiter, updated);
_buffSettings.BlacklistedSpellComponents = joined;
_combatSettings.BlacklistedSpellComponents = joined;
_profileNotice = $"Removed {removed}.";
RefreshItemEditors();
SaveProfile();
}
private static string[] Sorted(IEnumerable<string> names) =>
names.OrderBy(static name => name, StringComparer.Ordinal).ToArray();
private static void DeleteFromNamedSet(ISet<string> set, int row)
{
string[] names = Sorted(set);
if ((uint)row >= (uint)names.Length)
return;
set.Remove(names[row]);
}
private void ShowBuffPickerCore(bool forBlacklist)
{
_buffPickerForBlacklist = forBlacklist;
_buffPickerSearchText = string.Empty;
_selectedBuffPickerRow = 0;
_buffPickerVisible = true;
}
private void PickBuffAtCore(int row)
{
string[] rows = BuffPickerRows as string[] ?? BuffPickerRows.ToArray();
if ((uint)row >= (uint)rows.Length)
return;
string name = rows[row];
if (_buffPickerForBlacklist)
_buffSettings.BlacklistedBuffFamilyNames.Add(name);
else
_buffSettings.ExtraBuffSpellNames.Add(name);
_buffPickerVisible = false;
}
private static int ClampRow(int index, int count) => count == 0

View file

@ -101,6 +101,18 @@ public sealed class MossTankPlugin : IAcDreamPlugin
},
Path.Combine(directory, "mosstank-loot-editor.xml"),
_panel);
// Campaign VT S7.4: the Buffs tab's "Add..." picker (VTank's own
// SelfBuffChoiceView), same StartVisible=true/ShowInSidePanel=false
// popup pattern as the two panels above — see their own comments
// for why StartVisible must be true.
_host.Ui.AddPanel(
new PluginPanelDescriptor("buff-picker", "MossTank Add Buff")
{
StartVisible = true,
ShowInSidePanel = false,
},
Path.Combine(directory, "mosstank-buffpicker.xml"),
_panel);
_commandRegistration = _host.Commands.Register(
"vt",

View file

@ -0,0 +1,40 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Buff picker popup — VTank's own SelfBuffChoiceView (268x236, icon 8241,
docs/research/vtank-kb/08-ui-views.md §1's secondary-view table): a
search-filtered single-column exemplar list, opened from the Buffs tab's
two "Add..." buttons (cmdNewBuffSpell/cmdNewAntiBuffSpell — one picker
type, two destination lists, disambiguated at open time by which button
called ShowExtraBuffPicker/ShowBlacklistedBuffPicker). Same top-level
plugin-panel pattern fix round A established for mosstank-advanced.xml/
mosstank-loot-editor.xml: StartVisible=true, ShowInSidePanel=false, this
panel's own visible="{BuffPickerVisible}" is the SAME flag both "Add..."
buttons set and this popup's own "Cancel" button clears.
The KB's transcribed lblSearch/txtSearch geometry (4,168,384,16 /
4,188,260,16) carries VTank's own field width past its 268px window —
a source-XML artifact, not a real VVS layout (VVS itself would clip it
the same way our own contract test would reject it) — so both are
narrowed to fit the popup's own 268px canvas here, keeping VTank's
vertical rhythm (list, then "Search:", then the field) exactly.
KB's own lExemplarList has no explicit onchange row-click behavior
documented (only that txtSearch live-filters it) — picking a row here
commits it directly to whichever list is being edited and closes the
popup (PickBuffAt), rather than requiring a separate "OK" button VTank's
own view doesn't have either.
-->
<panel x="253" y="405" w="268" h="236" title="MossTank Add Buff"
visible="{BuffPickerVisible}" resize="none">
<list x="4" y="22" w="260" h="160" rowheight="17"
items="{BuffPickerRows}" selected="{SelectedBuffPickerIndex}"
onchange="{PickBuffAt}"
tooltip="Select a spell to add and close this picker." />
<label x="4" y="188" w="60" h="16" text="Search:" color="#FFE8DEC3" />
<field x="64" y="186" w="200" h="18" text="{BuffPickerSearchText}"
onchange="{SetBuffPickerSearchText}" maxlength="64"
clearonsubmit="false" background="#E6000000" color="#FFE8DEC3"
tooltip="Live-filters the list above by name." />
<button x="4" y="210" w="260" h="20" text="Cancel"
onclick="{HideBuffPicker}" />
</panel>

View file

@ -382,13 +382,24 @@
tooltip="Add the monster currently selected in the world." />
</group>
<!-- Items: exact-name profile membership drives weapons, wands and pets. -->
<!-- Items: VTank's own 2-column clWeaponName/clHandedness grid
(Campaign VT S7.4, docs/research/vtank-kb/08-ui-views.md §1 "Tab:
Items"; PluginCore.cs:8529-8562). PITCH per docs/plugin-ui-markup.md:
name 320+7=327, hands (last column) 80+7=87 (auto per the
last-column-always-auto rule, +16 scrollbar reserve folded into the
declared list width below rather than the column itself). Name-cell
click deletes the row (case 0); hands-cell click cycles handedness
(case 1) — session-local only, see DeleteItemRowAt/CycleItemHandsAt's
own doc comments for why handedness isn't persisted. -->
<group x="8" y="42" w="848" h="194" visible="{ItemsVisible}">
<label x="4" y="8" text="Weapons / Wands / Shields / Pets" color="#FFE8DEC3" />
<label x="438" y="8" text="Hands" color="#FFE8DEC3" />
<list x="4" y="28" w="414" h="112" rowheight="18"
items="{ItemRows}" selected="{SelectedItemRowIndex}"
onchange="{SelectItemRow}" tooltip="Select a profiled weapon, wand, shield, or pet device." />
<label x="4" y="8" w="327" text="Weapons / Wands / Shields / Pets" color="#FFE8DEC3" />
<label x="331" y="8" w="103" text="Hands" color="#FFE8DEC3" />
<list x="4" y="28" w="430" h="112" rowheight="18"
selected="{SelectedItemRowIndex}" onchange="{SelectItemRow}"
tooltip="Click a name to remove it, or Hands to cycle 1-Handed/2-Handed.">
<column type="text" width="327" items="{ItemNameColumn}" onclick="{DeleteItemRowAt}" />
<column type="text" width="103" items="{ItemHandsColumn}" onclick="{CycleItemHandsAt}" />
</list>
<button x="4" y="154" w="126" h="25" text="Add" onclick="{AddSelectedItem}" />
<button x="138" y="154" w="138" h="25" text="Add (no buffs)"
onclick="{AddSelectedItemNoBuffs}" />
@ -404,7 +415,13 @@
<label x="438" y="160" text="{ProfileNotice}" color="#FF9B9072" />
</group>
<!-- Consumables: the same exact-name profile VTank consults for phials. -->
<!-- Consumables: the same exact-name profile VTank consults for phials,
plus VTank's own right-hand "Excluded Scarab Types:" grid (Campaign
VT S7.4, docs/research/vtank-kb/08-ui-views.md §1 "Tab:
Consumables"; PluginCore.cs:7683-7776 — any cell click removes that
row; "Add Selected" requires a selected spell-component-class
object, see AddSelectedComponent's own doc comment). PITCH: icon
16+7=23, text column auto ("*", last column). -->
<group x="8" y="42" w="848" h="194" visible="{ConsumablesVisible}">
<label x="4" y="8"
text="Gems / Food / Kits / Potions / Charges / Grenades / Lockpicks"
@ -413,47 +430,84 @@
<list x="4" y="28" w="414" h="112" rowheight="18"
items="{ConsumableRows}" selected="{SelectedConsumableRowIndex}"
onchange="{SelectConsumableRow}" tooltip="Select a profiled consumable to remove." />
<list x="438" y="28" w="406" h="112" rowheight="18"
selected="{SelectedExcludedComponentIndex}" onchange="{SelectExcludedComponentRow}"
tooltip="Click a row to remove it from the blacklist.">
<column type="icon" width="23" iconkind="did" values="{ExcludedComponentIcons}"
onclick="{DeleteExcludedComponentAt}" />
<column type="text" width="*" items="{ExcludedComponentRows}"
onclick="{DeleteExcludedComponentAt}" />
</list>
<button x="4" y="154" w="176" h="25" text="Add"
onclick="{AddSelectedConsumable}" />
<button x="188" y="154" w="110" h="25" text="Add All Peas"
onclick="{AddAllPeas}" />
<button x="306" y="154" w="104" h="25" text="Remove"
onclick="{RemoveSelectedConsumable}" />
<label x="438" y="160" text="{ProfileNotice}" color="#FF9B9072" />
<button x="438" y="154" w="176" h="25" text="Add Selected"
onclick="{AddSelectedComponent}" />
<label x="438" y="184" text="{ProfileNotice}" color="#FF9B9072" />
</group>
<!-- Buffs: every visible switch maps to the live buff plan. -->
<!-- Buffs: every visible switch maps to the live buff plan, plus VTank's
own Extra Buff Spells / Blacklisted Buff Families lists (Campaign VT
S7.4, docs/research/vtank-kb/08-ui-views.md §1 "Tab: Buffs" — VTank's
REAL Buffs tab is only these two lists + two "Add..." buttons; the
category toggles/difficulty/rebuff/Buff-button below are MossTank's
own pre-existing extension, kept as a superset and compacted into
two columns to make room rather than removed). Any cell click on
either list removes that row (PluginCore.cs:7323-7355); "Add..."
opens the shared SelfBuffChoiceView-style picker
(mosstank-buffpicker.xml). List heights (84, not VTank's 116) and
widths (246/256, not VTank's 320) are narrowed to coexist with the
retained toggle columns in this tab's fixed 848x194 canvas — a
proportion-preserving compromise, not a pixel-for-pixel port
(owner's bar: "looks basically the same", never VVS pixels). -->
<group x="8" y="42" w="848" h="194" visible="{BuffsVisible}">
<toggle x="4" y="4" w="220" h="20" text="Trained skills only"
<toggle x="4" y="0" w="160" h="18" text="Trained skills only"
checked="{TrainedOnlyEnabled}" onclick="{ToggleTrainedOnly}" />
<toggle x="4" y="28" w="220" h="20" text="Attributes"
<toggle x="4" y="20" w="160" h="18" text="Attributes"
checked="{AttributesEnabled}" onclick="{ToggleAttributes}" />
<toggle x="4" y="52" w="220" h="20" text="Protections"
<toggle x="4" y="40" w="160" h="18" text="Protections"
checked="{ProtectionsEnabled}" onclick="{ToggleProtections}" />
<toggle x="4" y="76" w="220" h="20" text="Weapon auras"
<toggle x="4" y="60" w="160" h="18" text="Weapon auras"
checked="{AurasEnabled}" onclick="{ToggleAuras}" />
<toggle x="244" y="4" w="220" h="20" text="Armor banes"
<toggle x="168" y="0" w="160" h="18" text="Armor banes"
checked="{BanesEnabled}" onclick="{ToggleBanes}" />
<toggle x="244" y="28" w="220" h="20" text="Regeneration"
<toggle x="168" y="20" w="160" h="18" text="Regeneration"
checked="{RegenerationEnabled}" onclick="{ToggleRegeneration}" />
<toggle x="244" y="52" w="220" h="20" text="Other self-spells"
<toggle x="168" y="40" w="160" h="18" text="Other self-spells"
checked="{OtherEnabled}" onclick="{ToggleOther}" />
<label x="486" y="4" text="{DifficultyText}" color="#FFE8DEC3" />
<button x="670" y="0" w="30" h="23" text="-" onclick="{DifficultyDown}"
<label x="336" y="0" w="246" text="Extra Buff Spells" color="#FFE8DEC3"
tooltip="Named spell exemplars added beyond the school-driven picks (best similar will be used)." />
<list x="336" y="16" w="246" h="84" rowheight="17"
items="{ExtraBuffRows}" selected="{SelectedExtraBuffIndex}"
onchange="{DeleteExtraBuffAt}" tooltip="Click a row to remove it." />
<button x="336" y="102" w="120" h="16" text="Add..." onclick="{ShowExtraBuffPicker}" />
<label x="590" y="0" w="256" text="Blacklisted Buff Families" color="#FFE8DEC3"
tooltip="Named buff families never cast, even if otherwise wanted." />
<list x="590" y="16" w="256" h="84" rowheight="17"
items="{BlacklistedBuffFamilyRows}" selected="{SelectedBlacklistedBuffIndex}"
onchange="{DeleteBlacklistedBuffFamilyAt}" tooltip="Click a row to remove it." />
<button x="590" y="102" w="120" h="16" text="Add..." onclick="{ShowBlacklistedBuffPicker}" />
<label x="4" y="112" w="300" text="{DifficultyText}" color="#FFE8DEC3" />
<button x="308" y="108" w="26" h="20" text="-" onclick="{DifficultyDown}"
tooltip="Decrease the skill-over-difficulty margin." />
<button x="706" y="0" w="30" h="23" text="+" onclick="{DifficultyUp}"
<button x="338" y="108" w="26" h="20" text="+" onclick="{DifficultyUp}"
tooltip="Increase the skill-over-difficulty margin." />
<label x="486" y="34" text="{RebuffText}" color="#FFE8DEC3" />
<button x="670" y="28" w="30" h="23" text="-" onclick="{RebuffDown}"
<label x="4" y="134" w="300" text="{RebuffText}" color="#FFE8DEC3" />
<button x="308" y="130" w="26" h="20" text="-" onclick="{RebuffDown}"
tooltip="Rebuff later." />
<button x="706" y="28" w="30" h="23" text="+" onclick="{RebuffUp}"
<button x="338" y="130" w="26" h="20" text="+" onclick="{RebuffUp}"
tooltip="Rebuff earlier." />
<button x="4" y="124" w="140" h="26" text="{BuffButtonText}" onclick="{Buff}" />
<label x="164" y="130" text="{BuffStatus}" color="#FFC7B98F" />
<label x="4" y="160" text="{Coverage}" color="#FF9B9072" />
<button x="4" y="156" w="140" h="26" text="{BuffButtonText}" onclick="{Buff}" />
<label x="164" y="162" text="{BuffStatus}" color="#FFC7B98F" />
<label x="4" y="186" text="{Coverage}" color="#FF9B9072" />
</group>
<!-- Route: VTank's navigation-waypoint editor and execution surface. -->

View file

@ -29,6 +29,9 @@
<None Include="..\..\src\AcDream.Plugins.MossTank\mosstank-loot-editor.xml"
Link="mosstank-loot-editor.xml"
CopyToOutputDirectory="PreserveNewest" />
<None Include="..\..\src\AcDream.Plugins.MossTank\mosstank-buffpicker.xml"
Link="mosstank-buffpicker.xml"
CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
<ItemGroup>
<None Include="Fixtures\vtank\**\*.*" CopyToOutputDirectory="PreserveNewest" />

View file

@ -194,7 +194,15 @@ public sealed class MossTankMarkupContractTests
// three stacked name-draft field+"New"-button rows and the
// acdream-only "Loot engine" classifier menu (owner: legacy
// leftovers) — net 202 -> 167.
Assert.Equal(167, controls.Length);
// S7.4 replaced the Items tab's single-column list with VTank's own
// clWeaponName/clHandedness 2-column grid (+2: the list itself plus
// 2 <column> elements, vs. 1 for the old single-column list), added
// the Consumables tab's "Excluded Scarab Types" 2-column grid plus
// its "Add Selected" button (+4: 1 list + 2 columns + 1 button),
// and added the Buffs tab's Extra Buff Spells / Blacklisted Buff
// Families lists plus their two "Add..." buttons (+4: 2 lists + 2
// buttons) — net 167 -> 177.
Assert.Equal(177, controls.Length);
foreach (XElement control in controls)
{
@ -308,6 +316,7 @@ public sealed class MossTankMarkupContractTests
[Theory]
[InlineData("mosstank-advanced.xml", 392f, 300f)]
[InlineData("mosstank-loot-editor.xml", 268f, 300f)]
[InlineData("mosstank-buffpicker.xml", 268f, 236f)]
public void SecondaryPopupPanelsFitTheirOwnBoundsAndEveryBindingResolves(
string fileName, float expectedWidth, float expectedHeight)
{
@ -353,7 +362,7 @@ public sealed class MossTankMarkupContractTests
foreach (string fileName in new[]
{
"mosstank.xml", "mosstank-advanced.xml",
"mosstank-loot-editor.xml",
"mosstank-loot-editor.xml", "mosstank-buffpicker.xml",
})
{
XDocument document = XDocument.Load(

View file

@ -906,6 +906,112 @@ public sealed class MossTankPanelTests
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 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 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()
{