From 2faa696c4182c6625c80a0cac372fe168573fff1 Mon Sep 17 00:00:00 2001
From: Thiago Carvalho <836311+thiago-rcarvalho@users.noreply.github.com>
Date: Mon, 6 Apr 2026 18:35:31 -0300
Subject: [PATCH] Add inventory auto-stack and sort funcionality for exosuit,
starship, freighter and chests
---
Config/AppConfig.cs | 62 +++
Core/ExosuitAutoStackLogic.cs | 636 +++++++++++++++++++++++
NMSE.Tests/ExosuitAutoStackLogicTests.cs | 613 ++++++++++++++++++++++
NMSE.Tests/NMSE.Tests.csproj | 2 +
NMSE.csproj | 4 +
README.md | 2 +
Resources/ui/lang/en-GB.json | 18 +
Resources/ui/lang/pt-BR.json | 17 +
UI/MainForm.cs | 33 ++
UI/Panels/BasePanel.cs | 5 +
UI/Panels/ExosuitPanel.Designer.cs | 12 +
UI/Panels/ExosuitPanel.cs | 175 +++++++
UI/Panels/FreighterPanel.Designer.cs | 1 +
UI/Panels/InventoryGridPanel.Designer.cs | 81 ++-
UI/Panels/InventoryGridPanel.cs | 586 ++++++++++++++++++++-
UI/Panels/StarshipPanel.Designer.cs | 8 +
UI/Panels/StarshipPanel.cs | 179 +++++++
docs/user/README.md | 24 +
18 files changed, 2440 insertions(+), 18 deletions(-)
create mode 100644 Core/ExosuitAutoStackLogic.cs
create mode 100644 NMSE.Tests/ExosuitAutoStackLogicTests.cs
diff --git a/Config/AppConfig.cs b/Config/AppConfig.cs
index 42c9ceca..73451216 100644
--- a/Config/AppConfig.cs
+++ b/Config/AppConfig.cs
@@ -1,4 +1,6 @@
using NMSE.Models;
+using System.Security.Cryptography;
+using System.Text;
namespace NMSE.Config;
@@ -125,6 +127,66 @@ public int MainFrameHeight
set => SetProperty("MainFrame.Height", value.ToString());
}
+ public static string BuildSaveScopeKey(string? saveFilePath)
+ {
+ if (string.IsNullOrWhiteSpace(saveFilePath))
+ return "unknown";
+
+ string normalized;
+ try
+ {
+ normalized = Path.GetFullPath(saveFilePath).Trim();
+ }
+ catch
+ {
+ normalized = saveFilePath.Trim();
+ }
+
+ if (OperatingSystem.IsWindows())
+ normalized = normalized.ToLowerInvariant();
+
+ byte[] data = Encoding.UTF8.GetBytes(normalized);
+ byte[] hash = SHA256.HashData(data);
+ return Convert.ToHexString(hash[..8]).ToLowerInvariant();
+ }
+
+ private static string BuildPinnedSlotsPropertyKey(string saveScopeKey, string inventoryKey)
+ => $"PinnedSlots.{saveScopeKey}.{inventoryKey}";
+
+ public HashSet<(int x, int y)> GetPinnedSlots(string saveScopeKey, string inventoryKey)
+ {
+ var result = new HashSet<(int x, int y)>();
+ string? raw = GetProperty(BuildPinnedSlotsPropertyKey(saveScopeKey, inventoryKey));
+ if (string.IsNullOrWhiteSpace(raw))
+ return result;
+
+ var entries = raw.Split(';', StringSplitOptions.RemoveEmptyEntries);
+ foreach (var entry in entries)
+ {
+ var parts = entry.Split(',', StringSplitOptions.RemoveEmptyEntries);
+ if (parts.Length != 2)
+ continue;
+
+ if (int.TryParse(parts[0], out int x) && int.TryParse(parts[1], out int y))
+ result.Add((x, y));
+ }
+
+ return result;
+ }
+
+ public void SetPinnedSlots(string saveScopeKey, string inventoryKey, IEnumerable<(int x, int y)> pinnedSlots)
+ {
+ string key = BuildPinnedSlotsPropertyKey(saveScopeKey, inventoryKey);
+ string value = string.Join(";",
+ pinnedSlots
+ .Distinct()
+ .OrderBy(p => p.y)
+ .ThenBy(p => p.x)
+ .Select(p => $"{p.x},{p.y}"));
+
+ SetProperty(key, string.IsNullOrEmpty(value) ? null : value);
+ }
+
/// Creates the config directory and loads settings from disk if available.
public void Initialize()
{
diff --git a/Core/ExosuitAutoStackLogic.cs b/Core/ExosuitAutoStackLogic.cs
new file mode 100644
index 00000000..d0e112fe
--- /dev/null
+++ b/Core/ExosuitAutoStackLogic.cs
@@ -0,0 +1,636 @@
+using System.Text;
+using NMSE.Models;
+
+namespace NMSE.Core;
+
+///
+/// Moves item amounts from Exosuit cargo into existing matching stacks in Chest 1-10 inventories.
+/// Only chest stacks that already contain the same item are valid destinations.
+///
+internal static class ExosuitAutoStackLogic
+{
+ private sealed class ChestInventoryInfo
+ {
+ public required JsonObject Inventory { get; init; }
+ public required JsonArray Slots { get; init; }
+ public int ChestIndex { get; init; }
+ }
+
+ private sealed class ChestTarget
+ {
+ public required JsonObject Slot { get; init; }
+ public int SlotIndex { get; init; }
+ public int Amount { get; init; }
+ public int MaxAmount { get; init; }
+ }
+
+ public static bool AutoStackCargoToChests(
+ JsonObject cargoInventory,
+ JsonObject playerState,
+ out int movedUnits,
+ out int touchedCargoSlots,
+ ISet<(int x, int y)>? pinnedSourceSlots = null,
+ (int x, int y)? sourceSlotFilter = null,
+ string? sourceItemIdFilter = null)
+ {
+ movedUnits = 0;
+ touchedCargoSlots = 0;
+
+ var cargoSlots = cargoInventory.GetArray("Slots");
+ if (cargoSlots == null || cargoSlots.Length == 0)
+ return false;
+
+ var chestInventories = new List();
+ for (int i = 0; i < BaseLogic.ChestInventoryKeys.Length; i++)
+ {
+ var chestInventory = playerState.GetObject(BaseLogic.ChestInventoryKeys[i]);
+ var slots = chestInventory?.GetArray("Slots");
+ if (chestInventory != null && slots != null)
+ {
+ chestInventories.Add(new ChestInventoryInfo
+ {
+ Inventory = chestInventory,
+ Slots = slots,
+ ChestIndex = i,
+ });
+ }
+ }
+
+ if (chestInventories.Count == 0)
+ return false;
+
+ bool changed = false;
+
+ for (int cargoIndex = cargoSlots.Length - 1; cargoIndex >= 0; cargoIndex--)
+ {
+ JsonObject? cargoSlot;
+ try { cargoSlot = cargoSlots.GetObject(cargoIndex); }
+ catch { continue; }
+ if (cargoSlot == null || IsTechnologySlot(cargoSlot))
+ continue;
+
+ if (!ShouldProcessSourceSlot(cargoSlot, pinnedSourceSlots, sourceSlotFilter, sourceItemIdFilter, out _))
+ continue;
+
+ string itemId = ExtractSlotItemId(cargoSlot);
+ if (string.IsNullOrEmpty(itemId) || itemId == "^" || itemId == "^YOURSLOTITEM")
+ continue;
+
+ int sourceAmount;
+ try { sourceAmount = cargoSlot.GetInt("Amount"); }
+ catch { continue; }
+
+ if (sourceAmount <= 0)
+ continue;
+
+ var destinationChests = FindDestinationChests(chestInventories, itemId);
+ if (destinationChests.Count == 0)
+ continue;
+
+ int movedFromCargoSlot = 0;
+ foreach (var destinationChest in destinationChests)
+ {
+ movedFromCargoSlot = TryMoveToInventory(
+ sourceSlot: cargoSlot,
+ sourceAmount: sourceAmount,
+ itemId: itemId,
+ destination: destinationChest,
+ allowNewSlots: true);
+
+ if (movedFromCargoSlot > 0)
+ break;
+ }
+
+ if (movedFromCargoSlot <= 0)
+ continue;
+
+ int remaining = sourceAmount - movedFromCargoSlot;
+ movedUnits += movedFromCargoSlot;
+ touchedCargoSlots++;
+ changed = true;
+
+ if (remaining <= 0)
+ {
+ cargoSlots.RemoveAt(cargoIndex);
+ }
+ else
+ {
+ cargoSlot.Set("Amount", remaining);
+ }
+ }
+
+ return changed;
+ }
+
+ public static bool AutoStackCargoToStarship(
+ JsonObject cargoInventory,
+ JsonObject playerState,
+ out int movedUnits,
+ out int touchedCargoSlots,
+ ISet<(int x, int y)>? pinnedSourceSlots = null,
+ (int x, int y)? sourceSlotFilter = null,
+ string? sourceItemIdFilter = null)
+ {
+ movedUnits = 0;
+ touchedCargoSlots = 0;
+
+ var ships = playerState.GetArray("ShipOwnership");
+ if (ships == null || ships.Length == 0)
+ return false;
+
+ int primaryShip = 0;
+ try { primaryShip = playerState.GetInt("PrimaryShip"); }
+ catch { }
+
+ if (primaryShip < 0 || primaryShip >= ships.Length)
+ return false;
+
+ var ship = ships.GetObject(primaryShip);
+ var shipInventory = ship?.GetObject("Inventory");
+ if (shipInventory == null)
+ return false;
+
+ return AutoStackCargoToInventory(
+ cargoInventory,
+ shipInventory,
+ out movedUnits,
+ out touchedCargoSlots,
+ pinnedSourceSlots,
+ sourceSlotFilter,
+ sourceItemIdFilter);
+ }
+
+ public static bool AutoStackCargoToFreighter(
+ JsonObject cargoInventory,
+ JsonObject playerState,
+ out int movedUnits,
+ out int touchedCargoSlots,
+ ISet<(int x, int y)>? pinnedSourceSlots = null,
+ (int x, int y)? sourceSlotFilter = null,
+ string? sourceItemIdFilter = null)
+ {
+ movedUnits = 0;
+ touchedCargoSlots = 0;
+
+ var freighterInventory = playerState.GetObject("FreighterInventory");
+ if (freighterInventory == null)
+ return false;
+
+ return AutoStackCargoToInventory(
+ cargoInventory,
+ freighterInventory,
+ out movedUnits,
+ out touchedCargoSlots,
+ pinnedSourceSlots,
+ sourceSlotFilter,
+ sourceItemIdFilter);
+ }
+
+ public static bool AutoStackFromInventoryToInventory(
+ JsonObject sourceInventory,
+ JsonObject destinationInventory,
+ out int movedUnits,
+ out int touchedSourceSlots,
+ ISet<(int x, int y)>? pinnedSourceSlots = null,
+ (int x, int y)? sourceSlotFilter = null,
+ string? sourceItemIdFilter = null)
+ {
+ return AutoStackCargoToInventory(
+ sourceInventory,
+ destinationInventory,
+ out movedUnits,
+ out touchedSourceSlots,
+ pinnedSourceSlots,
+ sourceSlotFilter,
+ sourceItemIdFilter);
+ }
+
+ private static bool AutoStackCargoToInventory(
+ JsonObject cargoInventory,
+ JsonObject destinationInventory,
+ out int movedUnits,
+ out int touchedCargoSlots,
+ ISet<(int x, int y)>? pinnedSourceSlots = null,
+ (int x, int y)? sourceSlotFilter = null,
+ string? sourceItemIdFilter = null)
+ {
+ movedUnits = 0;
+ touchedCargoSlots = 0;
+
+ var cargoSlots = cargoInventory.GetArray("Slots");
+ var destinationSlots = destinationInventory.GetArray("Slots");
+ if (cargoSlots == null || cargoSlots.Length == 0 || destinationSlots == null)
+ return false;
+
+ bool changed = false;
+ var destination = new ChestInventoryInfo
+ {
+ Inventory = destinationInventory,
+ Slots = destinationSlots,
+ ChestIndex = -1,
+ };
+
+ for (int cargoIndex = cargoSlots.Length - 1; cargoIndex >= 0; cargoIndex--)
+ {
+ JsonObject? cargoSlot;
+ try { cargoSlot = cargoSlots.GetObject(cargoIndex); }
+ catch { continue; }
+ if (cargoSlot == null || IsTechnologySlot(cargoSlot))
+ continue;
+
+ if (!ShouldProcessSourceSlot(cargoSlot, pinnedSourceSlots, sourceSlotFilter, sourceItemIdFilter, out _))
+ continue;
+
+ string itemId = ExtractSlotItemId(cargoSlot);
+ if (string.IsNullOrEmpty(itemId) || itemId == "^" || itemId == "^YOURSLOTITEM")
+ continue;
+
+ int sourceAmount;
+ try { sourceAmount = cargoSlot.GetInt("Amount"); }
+ catch { continue; }
+
+ if (sourceAmount <= 0)
+ continue;
+
+ var targets = FindMatchingTargets(destination.Inventory, destination.Slots, itemId);
+ if (targets.Count == 0)
+ continue;
+
+ int movedFromCargoSlot = TryMoveToInventory(
+ sourceSlot: cargoSlot,
+ sourceAmount: sourceAmount,
+ itemId: itemId,
+ destination: destination,
+ allowNewSlots: true);
+
+ if (movedFromCargoSlot <= 0)
+ continue;
+
+ int remaining = sourceAmount - movedFromCargoSlot;
+ movedUnits += movedFromCargoSlot;
+ touchedCargoSlots++;
+ changed = true;
+
+ if (remaining <= 0)
+ cargoSlots.RemoveAt(cargoIndex);
+ else
+ cargoSlot.Set("Amount", remaining);
+ }
+
+ return changed;
+ }
+
+ private static List FindDestinationChests(List chestInventories, string itemId)
+ {
+ var withAvailableStack = new List();
+ var withFreeSlot = new List();
+
+ foreach (var chest in chestInventories)
+ {
+ var targets = FindMatchingTargets(chest.Inventory, chest.Slots, itemId);
+ if (targets.Count == 0)
+ continue;
+
+ foreach (var target in targets)
+ {
+ if (target.Amount < target.MaxAmount)
+ {
+ withAvailableStack.Add(chest);
+ goto NextChest;
+ }
+ }
+
+ if (GetAvailableChestPositions(chest.Inventory, chest.Slots).Count > 0)
+ withFreeSlot.Add(chest);
+
+ NextChest:;
+ }
+
+ withAvailableStack.AddRange(withFreeSlot);
+ return withAvailableStack;
+ }
+
+ private static List FindMatchingTargets(JsonObject inventory, JsonArray slots, string itemId)
+ {
+ var results = new List();
+
+ for (int i = 0; i < slots.Length; i++)
+ {
+ JsonObject? slot;
+ try { slot = slots.GetObject(i); }
+ catch { continue; }
+ if (slot == null || !IsSlotEnabled(inventory, slot))
+ continue;
+
+ string targetId = ExtractSlotItemId(slot);
+ if (!string.Equals(targetId, itemId, StringComparison.OrdinalIgnoreCase))
+ continue;
+
+ int amount = GetAmount(slot);
+ int max = GetMaxAmount(slot);
+ if (amount < 0 || max <= 0)
+ continue;
+
+ results.Add(new ChestTarget
+ {
+ Slot = slot,
+ SlotIndex = i,
+ Amount = amount,
+ MaxAmount = max,
+ });
+ }
+
+ results.Sort((a, b) =>
+ {
+ int byAmount = b.Amount.CompareTo(a.Amount);
+ return byAmount != 0 ? byAmount : a.SlotIndex.CompareTo(b.SlotIndex);
+ });
+
+ return results;
+ }
+
+ private static int TryMoveToInventory(JsonObject sourceSlot, int sourceAmount, string itemId, ChestInventoryInfo destination, bool allowNewSlots)
+ {
+ if (sourceAmount <= 0)
+ return 0;
+
+ var targets = FindMatchingTargets(destination.Inventory, destination.Slots, itemId);
+ if (targets.Count == 0)
+ return 0;
+
+ int targetMaxAmount = targets[0].MaxAmount > 0 ? targets[0].MaxAmount : GetMaxAmount(sourceSlot);
+ if (targetMaxAmount <= 0)
+ targetMaxAmount = sourceAmount;
+
+ int remaining = sourceAmount;
+ int movedUnits = 0;
+ foreach (var target in targets)
+ {
+ int transfer = Math.Min(remaining, target.MaxAmount - target.Amount);
+ if (transfer <= 0)
+ continue;
+
+ target.Slot.Set("Amount", target.Amount + transfer);
+ remaining -= transfer;
+ movedUnits += transfer;
+ }
+
+ if (!allowNewSlots || remaining <= 0)
+ return movedUnits;
+
+ var freePositions = GetAvailableChestPositions(destination.Inventory, destination.Slots);
+ foreach (var (x, y) in freePositions)
+ {
+ int transfer = Math.Min(remaining, targetMaxAmount);
+ if (transfer <= 0)
+ break;
+
+ var newSlot = InventorySlotHelper.DuplicateSlot(sourceSlot, x, y);
+ newSlot.Set("Amount", transfer);
+ newSlot.Set("MaxAmount", targetMaxAmount);
+ destination.Slots.Add(newSlot);
+ remaining -= transfer;
+ movedUnits += transfer;
+ }
+
+ return movedUnits;
+ }
+
+ private static List<(int x, int y)> GetAvailableChestPositions(JsonObject inventory, JsonArray slots)
+ {
+ var positions = new List<(int x, int y)>();
+ var occupied = new HashSet<(int x, int y)>();
+
+ for (int i = 0; i < slots.Length; i++)
+ {
+ JsonObject? slot;
+ try { slot = slots.GetObject(i); }
+ catch { continue; }
+ if (slot == null) continue;
+
+ if (TryGetSlotPosition(slot, out int slotX, out int slotY))
+ occupied.Add((slotX, slotY));
+ }
+
+ var validSlots = inventory.GetArray("ValidSlotIndices");
+ if (validSlots != null)
+ {
+ for (int i = 0; i < validSlots.Length; i++)
+ {
+ JsonObject? idx;
+ try { idx = validSlots.GetObject(i); }
+ catch { continue; }
+ if (idx == null) continue;
+
+ int x;
+ int y;
+ try
+ {
+ x = idx.GetInt("X");
+ y = idx.GetInt("Y");
+ }
+ catch
+ {
+ continue;
+ }
+
+ if (!occupied.Contains((x, y)))
+ positions.Add((x, y));
+ }
+ }
+ else
+ {
+ int width = 0;
+ int height = 0;
+ try { width = inventory.GetInt("Width"); } catch { }
+ try { height = inventory.GetInt("Height"); } catch { }
+
+ if (width <= 0 || height <= 0)
+ {
+ int maxX = -1;
+ int maxY = -1;
+ foreach (var (occupiedX, occupiedY) in occupied)
+ {
+ if (occupiedX > maxX) maxX = occupiedX;
+ if (occupiedY > maxY) maxY = occupiedY;
+ }
+
+ if (width <= 0) width = maxX >= 0 ? maxX + 1 : 0;
+ if (height <= 0) height = maxY >= 0 ? maxY + 1 : 0;
+ }
+
+ if (width > 0 && height > 0)
+ {
+ for (int y = 0; y < height; y++)
+ {
+ for (int x = 0; x < width; x++)
+ {
+ if (!occupied.Contains((x, y)))
+ positions.Add((x, y));
+ }
+ }
+ }
+ }
+
+ positions.Sort((a, b) =>
+ {
+ int byY = a.y.CompareTo(b.y);
+ return byY != 0 ? byY : a.x.CompareTo(b.x);
+ });
+ return positions;
+ }
+
+ private static bool IsSlotEnabled(JsonObject inventory, JsonObject slot)
+ {
+ if (!TryGetSlotPosition(slot, out int x, out int y))
+ return false;
+
+ var validSlots = inventory.GetArray("ValidSlotIndices");
+ if (validSlots == null)
+ return true;
+
+ for (int i = 0; i < validSlots.Length; i++)
+ {
+ JsonObject? idx;
+ try { idx = validSlots.GetObject(i); }
+ catch { continue; }
+ if (idx == null) continue;
+ if (idx.GetInt("X") == x && idx.GetInt("Y") == y)
+ return true;
+ }
+
+ return false;
+ }
+
+ private static bool TryGetSlotPosition(JsonObject slot, out int x, out int y)
+ {
+ x = 0;
+ y = 0;
+
+ try
+ {
+ var index = slot.GetObject("Index");
+ if (index == null)
+ return false;
+
+ x = index.GetInt("X");
+ y = index.GetInt("Y");
+ return true;
+ }
+ catch
+ {
+ return false;
+ }
+ }
+
+ private static bool ShouldProcessSourceSlot(
+ JsonObject slot,
+ ISet<(int x, int y)>? pinnedSourceSlots,
+ (int x, int y)? sourceSlotFilter,
+ string? sourceItemIdFilter,
+ out (int x, int y) sourcePosition)
+ {
+ sourcePosition = default;
+
+ if (!TryGetSlotPosition(slot, out int srcX, out int srcY))
+ return sourceSlotFilter == null;
+
+ sourcePosition = (srcX, srcY);
+
+ if (sourceSlotFilter != null && sourcePosition != sourceSlotFilter.Value)
+ return false;
+
+ if (pinnedSourceSlots != null && pinnedSourceSlots.Contains(sourcePosition))
+ return false;
+
+ if (string.IsNullOrEmpty(sourceItemIdFilter))
+ return true;
+
+ string slotItemId = ExtractSlotItemId(slot);
+ return string.Equals(slotItemId, sourceItemIdFilter, StringComparison.OrdinalIgnoreCase);
+ }
+
+ private static bool IsTechnologySlot(JsonObject slot)
+ {
+ try
+ {
+ var type = slot.GetObject("Type");
+ var inventoryType = type?.GetString("InventoryType") ?? "";
+ return string.Equals(inventoryType, "Technology", StringComparison.OrdinalIgnoreCase);
+ }
+ catch
+ {
+ return false;
+ }
+ }
+
+ private static int GetAmount(JsonObject slot)
+ {
+ try { return slot.GetInt("Amount"); }
+ catch { return 0; }
+ }
+
+ private static int GetMaxAmount(JsonObject slot)
+ {
+ try { return slot.GetInt("MaxAmount"); }
+ catch { return 0; }
+ }
+
+ private static string ExtractSlotItemId(JsonObject slot)
+ {
+ object? raw = slot.Get("Id");
+ if (raw is JsonObject idObject)
+ raw = idObject.Get("Id");
+
+ string id = raw switch
+ {
+ BinaryData data => BinaryDataToItemId(data),
+ string text => text,
+ _ => "",
+ };
+
+ if (string.IsNullOrEmpty(id))
+ return "";
+ if (id[0] == '^')
+ return id;
+ return "^" + id;
+ }
+
+ private static string BinaryDataToItemId(BinaryData data)
+ {
+ var bytes = data.ToByteArray();
+ var sb = new StringBuilder();
+ bool afterHash = false;
+
+ for (int i = 0; i < bytes.Length; i++)
+ {
+ int b = bytes[i] & 0xFF;
+ if (i == 0)
+ {
+ if (b != 0x5E)
+ return data.ToString();
+ sb.Append('^');
+ continue;
+ }
+
+ if (b == 0x23)
+ {
+ sb.Append('#');
+ afterHash = true;
+ continue;
+ }
+
+ if (afterHash)
+ {
+ sb.Append((char)b);
+ continue;
+ }
+
+ const string hexChars = "0123456789ABCDEF";
+ sb.Append(hexChars[(b >> 4) & 0xF]);
+ sb.Append(hexChars[b & 0xF]);
+ }
+
+ return sb.ToString();
+ }
+}
diff --git a/NMSE.Tests/ExosuitAutoStackLogicTests.cs b/NMSE.Tests/ExosuitAutoStackLogicTests.cs
new file mode 100644
index 00000000..d62839d0
--- /dev/null
+++ b/NMSE.Tests/ExosuitAutoStackLogicTests.cs
@@ -0,0 +1,613 @@
+using NMSE.Core;
+using NMSE.Models;
+
+namespace NMSE.Tests;
+
+public class ExosuitAutoStackLogicTests
+{
+ [Fact]
+ public void AutoStackCargoToChests_UsesSingleDestinationChestAndCreatesRemainderThere()
+ {
+ var playerState = CreatePlayerState(
+ cargoSlots:
+ [
+ MakeSlot("^FERRITE", 0, 0, 300, 1000)
+ ],
+ chests:
+ [
+ [MakeSlot("^FERRITE", 0, 0, 900, 1000)],
+ [],
+ [],
+ [],
+ [MakeSlot("^FERRITE", 1, 0, 200, 1000)],
+ [], [], [], [], []
+ ]);
+
+ bool changed = ExosuitAutoStackLogic.AutoStackCargoToChests(playerState.GetObject("Inventory")!, playerState, out int movedUnits, out int touchedCargoSlots);
+
+ Assert.True(changed);
+ Assert.Equal(300, movedUnits);
+ Assert.Equal(1, touchedCargoSlots);
+
+ var cargoSlots = playerState.GetObject("Inventory")!.GetArray("Slots")!;
+ Assert.Equal(0, cargoSlots.Length);
+
+ var chest1Slots = playerState.GetObject("Chest1Inventory")!.GetArray("Slots")!;
+ Assert.Equal(2, chest1Slots.Length);
+ Assert.Equal(1000, chest1Slots.GetObject(0).GetInt("Amount"));
+ Assert.Equal("^FERRITE", chest1Slots.GetObject(1).GetString("Id"));
+ Assert.Equal(200, chest1Slots.GetObject(1).GetInt("Amount"));
+
+ var chest5Slot = playerState.GetObject("Chest5Inventory")!.GetArray("Slots")!.GetObject(0);
+ Assert.Equal(200, chest5Slot.GetInt("Amount"));
+ }
+
+ [Fact]
+ public void AutoStackCargoToChests_LeavesCargoWhenItemDoesNotExistInAnyChest()
+ {
+ var playerState = CreatePlayerState(
+ cargoSlots:
+ [
+ MakeSlot("^CARBON", 0, 0, 150, 500)
+ ],
+ chests:
+ [
+ [MakeSlot("^FERRITE", 0, 0, 50, 1000)],
+ [], [], [], [], [], [], [], [], []
+ ]);
+
+ bool changed = ExosuitAutoStackLogic.AutoStackCargoToChests(playerState.GetObject("Inventory")!, playerState, out int movedUnits, out int touchedCargoSlots);
+
+ Assert.False(changed);
+ Assert.Equal(0, movedUnits);
+ Assert.Equal(0, touchedCargoSlots);
+
+ var cargoSlot = playerState.GetObject("Inventory")!.GetArray("Slots")!.GetObject(0);
+ Assert.Equal("^CARBON", cargoSlot.GetString("Id"));
+ Assert.Equal(150, cargoSlot.GetInt("Amount"));
+ }
+
+ [Fact]
+ public void AutoStackCargoToChests_CreatesNewSlotInSameChestForRemainder()
+ {
+ var playerState = CreatePlayerState(
+ cargoSlots:
+ [
+ MakeSlot("^OXYGEN", 2, 1, 500, 1000)
+ ],
+ chests:
+ [
+ [MakeSlot("^OXYGEN", 0, 0, 950, 1000)],
+ [], [], [], [], [], [], [], [], []
+ ]);
+
+ bool changed = ExosuitAutoStackLogic.AutoStackCargoToChests(playerState.GetObject("Inventory")!, playerState, out int movedUnits, out int touchedCargoSlots);
+
+ Assert.True(changed);
+ Assert.Equal(500, movedUnits);
+ Assert.Equal(1, touchedCargoSlots);
+
+ var cargoSlots = playerState.GetObject("Inventory")!.GetArray("Slots")!;
+ Assert.Equal(0, cargoSlots.Length);
+
+ var chestSlots = playerState.GetObject("Chest1Inventory")!.GetArray("Slots")!;
+ Assert.Equal(2, chestSlots.Length);
+ Assert.Equal(1000, chestSlots.GetObject(0).GetInt("Amount"));
+ Assert.Equal(450, chestSlots.GetObject(1).GetInt("Amount"));
+ }
+
+ [Fact]
+ public void AutoStackCargoToChests_CreatesNewSlotWhenMatchingDestinationStackIsAlreadyFull()
+ {
+ var playerState = CreatePlayerState(
+ cargoSlots:
+ [
+ MakeSlot("^OXYGEN", 0, 0, 300, 999)
+ ],
+ chests:
+ [
+ [MakeSlot("^OXYGEN", 0, 0, 999, 999)],
+ [], [], [], [], [], [], [], [], []
+ ],
+ validChestSlots:
+ [
+ [(0, 0), (1, 0), (2, 0), (3, 0), (4, 0), (5, 0), (6, 0)]
+ ]);
+
+ bool changed = ExosuitAutoStackLogic.AutoStackCargoToChests(playerState.GetObject("Inventory")!, playerState, out int movedUnits, out int touchedCargoSlots);
+
+ Assert.True(changed);
+ Assert.Equal(300, movedUnits);
+ Assert.Equal(1, touchedCargoSlots);
+
+ var cargoSlots = playerState.GetObject("Inventory")!.GetArray("Slots")!;
+ Assert.Equal(0, cargoSlots.Length);
+
+ var chestSlots = playerState.GetObject("Chest1Inventory")!.GetArray("Slots")!;
+ Assert.Equal(2, chestSlots.Length);
+ Assert.Equal(999, chestSlots.GetObject(0).GetInt("Amount"));
+ Assert.Equal("^OXYGEN", chestSlots.GetObject(1).GetString("Id"));
+ Assert.Equal(300, chestSlots.GetObject(1).GetInt("Amount"));
+ }
+
+ [Fact]
+ public void AutoStackCargoToChests_CreatesNewSlotWhenValidSlotIndicesIsMissing()
+ {
+ var playerState = CreatePlayerState(
+ cargoSlots:
+ [
+ MakeSlot("^MICROCHIP", 0, 0, 300, 999)
+ ],
+ chests:
+ [
+ [MakeSlot("^MICROCHIP", 0, 0, 999, 999)],
+ [], [], [], [], [], [], [], [], []
+ ]);
+
+ var chest1Inventory = playerState.GetObject("Chest1Inventory")!;
+ chest1Inventory.Set("ValidSlotIndices", null);
+
+ bool changed = ExosuitAutoStackLogic.AutoStackCargoToChests(playerState.GetObject("Inventory")!, playerState, out int movedUnits, out int touchedCargoSlots);
+
+ Assert.True(changed);
+ Assert.Equal(300, movedUnits);
+ Assert.Equal(1, touchedCargoSlots);
+
+ var cargoSlots = playerState.GetObject("Inventory")!.GetArray("Slots")!;
+ Assert.Equal(0, cargoSlots.Length);
+
+ var chestSlots = playerState.GetObject("Chest1Inventory")!.GetArray("Slots")!;
+ Assert.Equal(2, chestSlots.Length);
+ Assert.Equal(999, chestSlots.GetObject(0).GetInt("Amount"));
+ Assert.Equal("^MICROCHIP", chestSlots.GetObject(1).GetString("Id"));
+ Assert.Equal(300, chestSlots.GetObject(1).GetInt("Amount"));
+ }
+
+ [Fact]
+ public void AutoStackCargoToChests_PrefersExistingStackInOtherChestBeforeCreatingNewSlot()
+ {
+ var playerState = CreatePlayerState(
+ cargoSlots:
+ [
+ MakeSlot("^MICROCHIP", 0, 0, 120, 999)
+ ],
+ chests:
+ [
+ [MakeSlot("^MICROCHIP", 0, 0, 999, 999)],
+ [MakeSlot("^MICROCHIP", 1, 0, 40, 999)],
+ [], [], [], [], [], [], [], []
+ ],
+ validChestSlots:
+ [
+ [(0, 0), (1, 0), (2, 0)],
+ [(1, 0), (2, 0)]
+ ]);
+
+ bool changed = ExosuitAutoStackLogic.AutoStackCargoToChests(playerState.GetObject("Inventory")!, playerState, out int movedUnits, out int touchedCargoSlots);
+
+ Assert.True(changed);
+ Assert.Equal(120, movedUnits);
+ Assert.Equal(1, touchedCargoSlots);
+
+ var cargoSlots = playerState.GetObject("Inventory")!.GetArray("Slots")!;
+ Assert.Equal(0, cargoSlots.Length);
+
+ var chest1Slots = playerState.GetObject("Chest1Inventory")!.GetArray("Slots")!;
+ Assert.Equal(1, chest1Slots.Length);
+ Assert.Equal(999, chest1Slots.GetObject(0).GetInt("Amount"));
+
+ var chest2Slots = playerState.GetObject("Chest2Inventory")!.GetArray("Slots")!;
+ Assert.Equal(1, chest2Slots.Length);
+ Assert.Equal(160, chest2Slots.GetObject(0).GetInt("Amount"));
+ }
+
+ [Fact]
+ public void AutoStackCargoToChests_LeavesRemainderInCargoWhenDestinationChestHasNoFreeValidSlot()
+ {
+ var playerState = CreatePlayerState(
+ cargoSlots:
+ [
+ MakeSlot("^OXYGEN", 2, 1, 500, 1000)
+ ],
+ chests:
+ [
+ [MakeSlot("^OXYGEN", 0, 0, 950, 1000)]
+ ],
+ validChestSlots:
+ [
+ [(0, 0)]
+ ]);
+
+ bool changed = ExosuitAutoStackLogic.AutoStackCargoToChests(playerState.GetObject("Inventory")!, playerState, out int movedUnits, out int touchedCargoSlots);
+
+ Assert.True(changed);
+ Assert.Equal(50, movedUnits);
+ Assert.Equal(1, touchedCargoSlots);
+
+ var cargoSlot = playerState.GetObject("Inventory")!.GetArray("Slots")!.GetObject(0);
+ Assert.Equal(450, cargoSlot.GetInt("Amount"));
+
+ var chestSlot = playerState.GetObject("Chest1Inventory")!.GetArray("Slots")!.GetObject(0);
+ Assert.Equal(1000, chestSlot.GetInt("Amount"));
+ }
+
+ [Fact]
+ public void AutoStackCargoToChests_DoesNotUseBlockedMatchingSlotAsDestination()
+ {
+ var playerState = CreatePlayerState(
+ cargoSlots:
+ [
+ MakeSlot("^CARBON", 0, 0, 100, 500)
+ ],
+ chests:
+ [
+ [MakeSlot("^CARBON", 9, 9, 200, 500)]
+ ],
+ validChestSlots:
+ [
+ [(0, 0), (1, 0)]
+ ]);
+
+ bool changed = ExosuitAutoStackLogic.AutoStackCargoToChests(playerState.GetObject("Inventory")!, playerState, out int movedUnits, out int touchedCargoSlots);
+
+ Assert.False(changed);
+ Assert.Equal(0, movedUnits);
+ Assert.Equal(0, touchedCargoSlots);
+
+ var cargoSlot = playerState.GetObject("Inventory")!.GetArray("Slots")!.GetObject(0);
+ Assert.Equal(100, cargoSlot.GetInt("Amount"));
+ }
+
+ [Fact]
+ public void AutoStackCargoToChests_FallsBackToNextValidChestWhenFirstMatchingChestCannotReceive()
+ {
+ var playerState = CreatePlayerState(
+ cargoSlots:
+ [
+ MakeSlot("^SODIUM", 0, 0, 150, 999)
+ ],
+ chests:
+ [
+ [MakeSlot("^SODIUM", 0, 0, 999, 999)],
+ [MakeSlot("^SODIUM", 1, 0, 999, 999)],
+ [], [], [], [], [], [], [], []
+ ],
+ validChestSlots:
+ [
+ [(0, 0)],
+ [(1, 0), (2, 0)]
+ ]);
+
+ bool changed = ExosuitAutoStackLogic.AutoStackCargoToChests(playerState.GetObject("Inventory")!, playerState, out int movedUnits, out int touchedCargoSlots);
+
+ Assert.True(changed);
+ Assert.Equal(150, movedUnits);
+ Assert.Equal(1, touchedCargoSlots);
+
+ var cargoSlots = playerState.GetObject("Inventory")!.GetArray("Slots")!;
+ Assert.Equal(0, cargoSlots.Length);
+
+ var chest1Slots = playerState.GetObject("Chest1Inventory")!.GetArray("Slots")!;
+ Assert.Equal(1, chest1Slots.Length);
+ Assert.Equal(999, chest1Slots.GetObject(0).GetInt("Amount"));
+
+ var chest2Slots = playerState.GetObject("Chest2Inventory")!.GetArray("Slots")!;
+ Assert.Equal(2, chest2Slots.Length);
+ Assert.Equal(999, chest2Slots.GetObject(0).GetInt("Amount"));
+ Assert.Equal("^SODIUM", chest2Slots.GetObject(1).GetString("Id"));
+ Assert.Equal(150, chest2Slots.GetObject(1).GetInt("Amount"));
+ }
+
+ [Fact]
+ public void AutoStackCargoToStarship_MovesToCurrentShipAndCreatesRemainderSlot()
+ {
+ var playerState = CreatePlayerState(
+ cargoSlots:
+ [
+ MakeSlot("^CARBON", 0, 0, 150, 999)
+ ],
+ chests:
+ [
+ [], [], [], [], [], [], [], [], [], []
+ ]);
+
+ var ship = new JsonObject();
+ ship.Add("Inventory", MakeInventory([MakeSlot("^CARBON", 0, 0, 900, 999)]));
+ ship.Add("Inventory_TechOnly", MakeInventory([]));
+ var ships = new JsonArray();
+ ships.Add(ship);
+ playerState.Add("ShipOwnership", ships);
+ playerState.Add("PrimaryShip", 0);
+
+ bool changed = ExosuitAutoStackLogic.AutoStackCargoToStarship(playerState.GetObject("Inventory")!, playerState, out int movedUnits, out int touchedCargoSlots);
+
+ Assert.True(changed);
+ Assert.Equal(150, movedUnits);
+ Assert.Equal(1, touchedCargoSlots);
+
+ var cargoSlots = playerState.GetObject("Inventory")!.GetArray("Slots")!;
+ Assert.Equal(0, cargoSlots.Length);
+
+ var shipSlots = playerState.GetArray("ShipOwnership")!.GetObject(0).GetObject("Inventory")!.GetArray("Slots")!;
+ Assert.Equal(2, shipSlots.Length);
+ Assert.Equal(999, shipSlots.GetObject(0).GetInt("Amount"));
+ Assert.Equal("^CARBON", shipSlots.GetObject(1).GetString("Id"));
+ Assert.Equal(51, shipSlots.GetObject(1).GetInt("Amount"));
+ }
+
+ [Fact]
+ public void AutoStackCargoToFreighter_LeavesCargoWhenItemDoesNotExistInFreighter()
+ {
+ var playerState = CreatePlayerState(
+ cargoSlots:
+ [
+ MakeSlot("^CARBON", 0, 0, 150, 999)
+ ],
+ chests:
+ [
+ [], [], [], [], [], [], [], [], [], []
+ ]);
+
+ playerState.Add("FreighterInventory", MakeInventory([MakeSlot("^FERRITE", 0, 0, 400, 999)]));
+ playerState.Add("FreighterInventory_TechOnly", MakeInventory([]));
+
+ bool changed = ExosuitAutoStackLogic.AutoStackCargoToFreighter(playerState.GetObject("Inventory")!, playerState, out int movedUnits, out int touchedCargoSlots);
+
+ Assert.False(changed);
+ Assert.Equal(0, movedUnits);
+ Assert.Equal(0, touchedCargoSlots);
+
+ var cargoSlot = playerState.GetObject("Inventory")!.GetArray("Slots")!.GetObject(0);
+ Assert.Equal("^CARBON", cargoSlot.GetString("Id"));
+ Assert.Equal(150, cargoSlot.GetInt("Amount"));
+ }
+
+ [Fact]
+ public void AutoStackCargoToStarship_SkipsPinnedSourceSlots()
+ {
+ var playerState = CreatePlayerState(
+ cargoSlots:
+ [
+ MakeSlot("^CARBON", 0, 0, 120, 999)
+ ],
+ chests:
+ [
+ [], [], [], [], [], [], [], [], [], []
+ ]);
+
+ var ship = new JsonObject();
+ ship.Add("Inventory", MakeInventory([MakeSlot("^CARBON", 0, 1, 500, 999)]));
+ ship.Add("Inventory_TechOnly", MakeInventory([]));
+ var ships = new JsonArray();
+ ships.Add(ship);
+ playerState.Add("ShipOwnership", ships);
+ playerState.Add("PrimaryShip", 0);
+
+ var pinned = new HashSet<(int x, int y)> { (0, 0) };
+ bool changed = ExosuitAutoStackLogic.AutoStackCargoToStarship(playerState.GetObject("Inventory")!, playerState, out int movedUnits, out int touchedCargoSlots, pinned);
+
+ Assert.False(changed);
+ Assert.Equal(0, movedUnits);
+ Assert.Equal(0, touchedCargoSlots);
+
+ var cargoSlot = playerState.GetObject("Inventory")!.GetArray("Slots")!.GetObject(0);
+ Assert.Equal(120, cargoSlot.GetInt("Amount"));
+
+ var shipSlot = playerState.GetArray("ShipOwnership")!.GetObject(0).GetObject("Inventory")!.GetArray("Slots")!.GetObject(0);
+ Assert.Equal(500, shipSlot.GetInt("Amount"));
+ }
+
+ [Fact]
+ public void AutoStackCargoToChests_WithSourceSlotFilter_MovesOnlySelectedSlot()
+ {
+ var playerState = CreatePlayerState(
+ cargoSlots:
+ [
+ MakeSlot("^CARBON", 0, 0, 100, 999),
+ MakeSlot("^OXYGEN", 1, 0, 80, 999)
+ ],
+ chests:
+ [
+ [MakeSlot("^CARBON", 0, 0, 700, 999), MakeSlot("^OXYGEN", 1, 0, 600, 999)],
+ [], [], [], [], [], [], [], [], []
+ ]);
+
+ bool changed = ExosuitAutoStackLogic.AutoStackCargoToChests(
+ playerState.GetObject("Inventory")!,
+ playerState,
+ out int movedUnits,
+ out int touchedCargoSlots,
+ sourceSlotFilter: (1, 0),
+ sourceItemIdFilter: "^OXYGEN");
+
+ Assert.True(changed);
+ Assert.Equal(80, movedUnits);
+ Assert.Equal(1, touchedCargoSlots);
+
+ var cargoSlots = playerState.GetObject("Inventory")!.GetArray("Slots")!;
+ Assert.Equal(1, cargoSlots.Length);
+ Assert.Equal("^CARBON", cargoSlots.GetObject(0).GetString("Id"));
+ Assert.Equal(100, cargoSlots.GetObject(0).GetInt("Amount"));
+
+ var chestSlots = playerState.GetObject("Chest1Inventory")!.GetArray("Slots")!;
+ Assert.Equal(680, chestSlots.GetObject(1).GetInt("Amount"));
+ }
+
+ [Fact]
+ public void AutoStackCargoToChests_WithSourceSlotFilterAndMismatchedItem_DoesNothing()
+ {
+ var playerState = CreatePlayerState(
+ cargoSlots:
+ [
+ MakeSlot("^CARBON", 0, 0, 100, 999)
+ ],
+ chests:
+ [
+ [MakeSlot("^CARBON", 0, 0, 500, 999)],
+ [], [], [], [], [], [], [], [], []
+ ]);
+
+ bool changed = ExosuitAutoStackLogic.AutoStackCargoToChests(
+ playerState.GetObject("Inventory")!,
+ playerState,
+ out int movedUnits,
+ out int touchedCargoSlots,
+ sourceSlotFilter: (0, 0),
+ sourceItemIdFilter: "^OXYGEN");
+
+ Assert.False(changed);
+ Assert.Equal(0, movedUnits);
+ Assert.Equal(0, touchedCargoSlots);
+
+ var cargoSlot = playerState.GetObject("Inventory")!.GetArray("Slots")!.GetObject(0);
+ Assert.Equal("^CARBON", cargoSlot.GetString("Id"));
+ Assert.Equal(100, cargoSlot.GetInt("Amount"));
+ }
+
+ [Fact]
+ public void AutoStackFromInventoryToInventory_WithSourceSlotFilter_MovesOnlySelectedSlot()
+ {
+ var sourceInventory = MakeInventory(
+ [
+ MakeSlot("^CARBON", 0, 0, 100, 999),
+ MakeSlot("^OXYGEN", 1, 0, 75, 999)
+ ]);
+
+ var destinationInventory = MakeInventory(
+ [
+ MakeSlot("^CARBON", 2, 0, 500, 999),
+ MakeSlot("^OXYGEN", 3, 0, 600, 999)
+ ]);
+
+ bool changed = ExosuitAutoStackLogic.AutoStackFromInventoryToInventory(
+ sourceInventory,
+ destinationInventory,
+ out int movedUnits,
+ out int touchedSourceSlots,
+ sourceSlotFilter: (1, 0),
+ sourceItemIdFilter: "^OXYGEN");
+
+ Assert.True(changed);
+ Assert.Equal(75, movedUnits);
+ Assert.Equal(1, touchedSourceSlots);
+
+ var sourceSlots = sourceInventory.GetArray("Slots")!;
+ Assert.Equal(1, sourceSlots.Length);
+ Assert.Equal("^CARBON", sourceSlots.GetObject(0).GetString("Id"));
+ Assert.Equal(100, sourceSlots.GetObject(0).GetInt("Amount"));
+
+ var destinationSlots = destinationInventory.GetArray("Slots")!;
+ Assert.Equal(675, destinationSlots.GetObject(1).GetInt("Amount"));
+ }
+
+ [Fact]
+ public void AutoStackFromInventoryToInventory_WithPinnedSelectedSlot_DoesNothing()
+ {
+ var sourceInventory = MakeInventory(
+ [
+ MakeSlot("^OXYGEN", 1, 0, 75, 999)
+ ]);
+
+ var destinationInventory = MakeInventory(
+ [
+ MakeSlot("^OXYGEN", 3, 0, 600, 999)
+ ]);
+
+ var pinned = new HashSet<(int x, int y)> { (1, 0) };
+
+ bool changed = ExosuitAutoStackLogic.AutoStackFromInventoryToInventory(
+ sourceInventory,
+ destinationInventory,
+ out int movedUnits,
+ out int touchedSourceSlots,
+ pinned,
+ sourceSlotFilter: (1, 0),
+ sourceItemIdFilter: "^OXYGEN");
+
+ Assert.False(changed);
+ Assert.Equal(0, movedUnits);
+ Assert.Equal(0, touchedSourceSlots);
+
+ var sourceSlot = sourceInventory.GetArray("Slots")!.GetObject(0);
+ Assert.Equal(75, sourceSlot.GetInt("Amount"));
+ var destinationSlot = destinationInventory.GetArray("Slots")!.GetObject(0);
+ Assert.Equal(600, destinationSlot.GetInt("Amount"));
+ }
+
+ private static JsonObject CreatePlayerState(List cargoSlots, List> chests, List>? validChestSlots = null)
+ {
+ var playerState = new JsonObject();
+ playerState.Add("Inventory", MakeInventory(cargoSlots));
+ playerState.Add("Inventory_TechOnly", MakeInventory([]));
+
+ for (int i = 0; i < 10; i++)
+ {
+ var chestSlots = i < chests.Count ? chests[i] : [];
+ List<(int x, int y)>? validSlots = null;
+ if (validChestSlots != null && i < validChestSlots.Count)
+ validSlots = validChestSlots[i];
+ playerState.Add($"Chest{i + 1}Inventory", MakeInventory(chestSlots, validSlots));
+ }
+
+ return playerState;
+ }
+
+ private static JsonObject MakeInventory(List slots, List<(int x, int y)>? validSlots = null)
+ {
+ var inventory = new JsonObject();
+ inventory.Add("Width", 10);
+ inventory.Add("Height", 12);
+
+ var slotArray = new JsonArray();
+ foreach (var slot in slots)
+ slotArray.Add(slot);
+ inventory.Add("Slots", slotArray);
+
+ var valid = new JsonArray();
+ if (validSlots != null)
+ {
+ foreach (var (x, y) in validSlots)
+ {
+ var idx = new JsonObject();
+ idx.Add("X", x);
+ idx.Add("Y", y);
+ valid.Add(idx);
+ }
+ }
+ else
+ {
+ for (int y = 0; y < 12; y++)
+ {
+ for (int x = 0; x < 10; x++)
+ {
+ var idx = new JsonObject();
+ idx.Add("X", x);
+ idx.Add("Y", y);
+ valid.Add(idx);
+ }
+ }
+ }
+ inventory.Add("ValidSlotIndices", valid);
+ return inventory;
+ }
+
+ private static JsonObject MakeSlot(string itemId, int x, int y, int amount, int maxAmount)
+ {
+ var slot = new JsonObject();
+ var type = new JsonObject();
+ type.Add("InventoryType", "Product");
+ slot.Add("Type", type);
+ slot.Add("Id", itemId);
+ slot.Add("Amount", amount);
+ slot.Add("MaxAmount", maxAmount);
+ slot.Add("DamageFactor", 0.0);
+ slot.Add("FullyInstalled", true);
+ slot.Add("AddedAutomatically", false);
+
+ var index = new JsonObject();
+ index.Add("X", x);
+ index.Add("Y", y);
+ slot.Add("Index", index);
+ return slot;
+ }
+}
diff --git a/NMSE.Tests/NMSE.Tests.csproj b/NMSE.Tests/NMSE.Tests.csproj
index a01a94e1..285d63b0 100644
--- a/NMSE.Tests/NMSE.Tests.csproj
+++ b/NMSE.Tests/NMSE.Tests.csproj
@@ -92,6 +92,8 @@
+
+
diff --git a/NMSE.csproj b/NMSE.csproj
index 09209a3d..6470bd40 100644
--- a/NMSE.csproj
+++ b/NMSE.csproj
@@ -55,6 +55,10 @@
+
+
+
+
diff --git a/README.md b/README.md
index 4f69f277..0846fbc4 100644
--- a/README.md
+++ b/README.md
@@ -68,6 +68,8 @@ You can see a set of thorough comparisons between the two most popular editors [
### ποΈ Inventories & More
- Visual inventory grid editor for every slot type
- Drag-and-drop item management
+- Auto-stack items from exosuit/starship to freighter/chests/starship
+- Sort inventory
- Import/export practically everything (cross-editor compatible)
- ByteBeat music library editor
- Recipe browser with full crafting trees
diff --git a/Resources/ui/lang/en-GB.json b/Resources/ui/lang/en-GB.json
index 4e6de3bd..3b9e556d 100644
--- a/Resources/ui/lang/en-GB.json
+++ b/Resources/ui/lang/en-GB.json
@@ -591,8 +591,16 @@
"inventory.ctx_repair_all": "Repair All Slots",
"inventory.ctx_repair_slot": "Repair Slot",
"inventory.ctx_replace_item": "Replace Item",
+ "inventory.ctx_pin_slot": "Pin Slot",
+ "inventory.ctx_unpin_slot": "Unpin Slot",
+ "inventory.ctx_sort_category": "Sort by Category",
+ "inventory.ctx_sort_name": "Sort by Name",
"inventory.ctx_supercharge": "Supercharge Slot",
"inventory.ctx_supercharge_all": "Supercharge All Slots",
+ "inventory.ctx_auto_stack_chests": "Auto-Stack to Chests",
+ "inventory.ctx_auto_stack_starship": "Auto-Stack to Starship",
+ "inventory.ctx_auto_stack_freighter": "Auto-Stack to Freighter",
+ "inventory.auto_stack_pinned_slot_blocked": "This slot is pinned and cannot be auto-stacked individually.",
"inventory.empty_slot": "(empty slot)",
"inventory.enter_item_id": "Enter or select an Item ID first.",
"inventory.export_error": "Export Error",
@@ -630,13 +638,23 @@
"inventory.search": "Search",
"inventory.search_placeholder": "Search items...",
"inventory.select_item_first": "Select an item from the Item Picker first, or enter an Item ID.",
+ "inventory.sort_category": "Category",
+ "inventory.sort_name": "Name",
+ "inventory.sort_none": "None",
"inventory.slot_details": "Slot Details",
+ "inventory.toolbar_auto_stack": "Auto-Stack",
+ "inventory.toolbar_auto_stack_chests": "To Chests",
+ "inventory.toolbar_auto_stack_starship": "To Starship",
+ "inventory.toolbar_auto_stack_freighter": "To Freighter",
+ "inventory.toolbar_sort": "Sort:",
"inventory.supercharge_added_msg": "Slot ({0},{1}) supercharged. Total: {2}/{3}",
"inventory.supercharge_max_msg": "This inventory already has the maximum number of supercharged slots ({0}).",
"inventory.supercharge_title": "Supercharge",
"inventory.tooltip_disabled": "Disabled slot ({0}, {1}) - Right-click to enable",
"inventory.tooltip_empty": "Empty slot ({0}, {1})",
"inventory.tooltip_empty_slot": "Empty slot ({0}, {1}) - Right-click to add item",
+ "inventory.tooltip_pinned_slot": "Pinned slot - auto-stack will not move this slot out",
+ "inventory.tooltip_unpinned_slot": "Click to pin this slot for auto-stack",
"inventory.width": "Width:",
"item_category.alien_lifeforms": "Alien Lifeforms",
"item_category.alien_ship": "Alien Ship",
diff --git a/Resources/ui/lang/pt-BR.json b/Resources/ui/lang/pt-BR.json
index 888c1486..45e75f7d 100644
--- a/Resources/ui/lang/pt-BR.json
+++ b/Resources/ui/lang/pt-BR.json
@@ -591,8 +591,15 @@
"inventory.ctx_repair_all": "Reparar todos os slots",
"inventory.ctx_repair_slot": "Reparar slot",
"inventory.ctx_replace_item": "Replace Item",
+ "inventory.ctx_pin_slot": "Fixar slot",
+ "inventory.ctx_unpin_slot": "Desafixar slot",
+ "inventory.ctx_sort_category": "Ordenar por categoria",
+ "inventory.ctx_sort_name": "Ordenar por nome",
"inventory.ctx_supercharge": "Sobrecarregar slot",
"inventory.ctx_supercharge_all": "Sobrecarregar todos os slots",
+ "inventory.ctx_auto_stack_chests": "Auto-stack para baus",
+ "inventory.ctx_auto_stack_starship": "Auto-stack para starship",
+ "inventory.ctx_auto_stack_freighter": "Auto-stack para freighter",
"inventory.empty_slot": "(empty slot)",
"inventory.enter_item_id": "Insira ou selecione um ID de item primeiro.",
"inventory.export_error": "Erro ao exportar",
@@ -630,13 +637,23 @@
"inventory.search": "Buscar",
"inventory.search_placeholder": "Pesquisar itensβ¦",
"inventory.select_item_first": "Selecione um item primeiro ou insira um ID de item.",
+ "inventory.sort_category": "Categoria",
+ "inventory.sort_name": "Nome",
+ "inventory.sort_none": "Nenhum",
"inventory.slot_details": "Slot Details",
+ "inventory.toolbar_auto_stack": "Auto-stack",
+ "inventory.toolbar_auto_stack_chests": "Para baus",
+ "inventory.toolbar_auto_stack_starship": "Para starship",
+ "inventory.toolbar_auto_stack_freighter": "Para freighter",
+ "inventory.toolbar_sort": "Ordenar:",
"inventory.supercharge_added_msg": "Slot ({0},{1}) supercharged. Total: {2}/{3}",
"inventory.supercharge_max_msg": "This inventory already has the maximum number of supercharged slots ({0}).",
"inventory.supercharge_title": "Supercarga",
"inventory.tooltip_disabled": "Disabled slot ({0}, {1}) - Right-click to enable",
"inventory.tooltip_empty": "Empty slot ({0}, {1})",
"inventory.tooltip_empty_slot": "Empty slot ({0}, {1}) - Right-click to add item",
+ "inventory.tooltip_pinned_slot": "Slot fixado - o auto-stack nao movera este slot",
+ "inventory.tooltip_unpinned_slot": "Clique para fixar este slot no auto-stack",
"inventory.width": "Largura:",
"item_category.alien_lifeforms": "Alien Lifeforms",
"item_category.alien_ship": "Alien Ship",
diff --git a/UI/MainForm.cs b/UI/MainForm.cs
index a3b6535c..f972e35e 100644
--- a/UI/MainForm.cs
+++ b/UI/MainForm.cs
@@ -139,8 +139,10 @@ public MainFormResources()
// Track unsaved changes from inventory grids
_exosuitPanel.DataModified += (s, e) => _hasUnsavedChanges = true;
+ _exosuitPanel.CrossInventoryTransferCompleted += OnExosuitCrossInventoryTransferCompleted;
_multitoolPanel.DataModified += (s, e) => _hasUnsavedChanges = true;
_shipPanel.DataModified += (s, e) => _hasUnsavedChanges = true;
+ _shipPanel.CrossInventoryTransferCompleted += OnStarshipCrossInventoryTransferCompleted;
_freighterPanel.DataModified += (s, e) => _hasUnsavedChanges = true;
_vehiclePanel.DataModified += (s, e) => _hasUnsavedChanges = true;
_cataloguePanel.DataModified += (s, e) => _hasUnsavedChanges = true;
@@ -433,6 +435,35 @@ private void OnTabChanged(object? sender, EventArgs e)
}
}
+ private void OnExosuitCrossInventoryTransferCompleted(object? sender, EventArgs e)
+ {
+ if (_currentSaveData == null)
+ return;
+
+ // Refresh loaded destination panels so transferred items appear immediately.
+ if (_loadedTabIndices.Contains(3)) // Starships
+ _shipPanel.LoadData(_currentSaveData);
+
+ if (_loadedTabIndices.Contains(4)) // Fleet (Freighter is inside)
+ _fleetPanel.LoadData(_currentSaveData);
+
+ if (_loadedTabIndices.Contains(7)) // Bases & Storage (includes Chests)
+ _basePanel.LoadData(_currentSaveData);
+ }
+
+ private void OnStarshipCrossInventoryTransferCompleted(object? sender, EventArgs e)
+ {
+ if (_currentSaveData == null)
+ return;
+
+ // Refresh loaded destination panels so transferred items appear immediately.
+ if (_loadedTabIndices.Contains(4)) // Fleet (Freighter is inside)
+ _fleetPanel.LoadData(_currentSaveData);
+
+ if (_loadedTabIndices.Contains(7)) // Bases & Storage (includes Chests)
+ _basePanel.LoadData(_currentSaveData);
+ }
+
///
/// Returns the content panel inside a tab page, or null if the page is null/empty.
///
@@ -458,12 +489,14 @@ private void LoadPanelForTab(int tabIndex)
_mainStatsPanel.LoadAccountData(_accountPanel.AccountData);
break;
case 1: // Exosuit
+ _exosuitPanel.SetSaveScopeKey(AppConfig.BuildSaveScopeKey(_currentFilePath));
_exosuitPanel.LoadData(_currentSaveData);
break;
case 2: // Multi-tool
_multitoolPanel.LoadData(_currentSaveData);
break;
case 3: // Starships
+ _shipPanel.SetSaveScopeKey(AppConfig.BuildSaveScopeKey(_currentFilePath));
_shipPanel.LoadData(_currentSaveData);
break;
case 4: // Fleet (loads all three sub-panels)
diff --git a/UI/Panels/BasePanel.cs b/UI/Panels/BasePanel.cs
index d2b8e342..ba193ec1 100644
--- a/UI/Panels/BasePanel.cs
+++ b/UI/Panels/BasePanel.cs
@@ -976,6 +976,7 @@ public ChestsSubPanel()
_chestGrids[i].SetIsStorageInventory(true);
_chestGrids[i].SetIsChestInventory(true);
_chestGrids[i].SetIsCargoInventory(true);
+ _chestGrids[i].SetSortingEnabled(true);
_chestGrids[i].SetInventoryGroup("Chest");
// Container panel with name row + warning label above the inventory grid
@@ -1206,6 +1207,7 @@ public void ApplyUiLocalisation()
_chestNameFields[i].PlaceholderText = UiStrings.Get("base.chest_name_placeholder");
_chestRenameButtons[i].Text = UiStrings.Get("base.chest_rename");
_chestClearButtons[i].Text = UiStrings.Get("base.chest_clear_name");
+ _chestGrids[i].ApplyUiLocalisation();
}
}
}
@@ -1439,6 +1441,9 @@ public void ApplyUiLocalisation()
_storageTabs.TabPages[7].Text = UiStrings.Get("base.storage_freighter_refund");
_freighterRefundWarning.Text = UiStrings.Get("base.storage_freighter_refund_warning");
}
+
+ foreach (var tab in _tabs)
+ tab.Grid.ApplyUiLocalisation();
}
}
diff --git a/UI/Panels/ExosuitPanel.Designer.cs b/UI/Panels/ExosuitPanel.Designer.cs
index 80d2e7a2..d370f6f6 100644
--- a/UI/Panels/ExosuitPanel.Designer.cs
+++ b/UI/Panels/ExosuitPanel.Designer.cs
@@ -93,12 +93,24 @@ private void SetupLayout()
{
_techGrid.SetIsTechInventory(true);
_generalGrid.SetIsCargoInventory(true);
+ _generalGrid.SetSortingEnabled(true);
+ _techGrid.SetSortingEnabled(false);
_techGrid.SetInventoryOwnerType("Suit");
_generalGrid.SetInventoryOwnerType("Suit");
_generalGrid.SetInventoryGroup("PersonalCargo");
+ _generalGrid.SetPinSlotFeatureEnabled(true);
_techGrid.SetInventoryGroup("Personal");
_generalGrid.DataModified += (s, e) => DataModified?.Invoke(this, e);
_techGrid.DataModified += (s, e) => DataModified?.Invoke(this, e);
+ _generalGrid.PinnedSlotsChanged += OnPinnedSlotsChanged;
+ _generalGrid.AutoStackToStorageRequested += OnAutoStackToStorageRequested;
+ _generalGrid.AutoStackToStarshipRequested += OnAutoStackToStarshipRequested;
+ _generalGrid.AutoStackToFreighterRequested += OnAutoStackToFreighterRequested;
+ _generalGrid.AutoStackSelectedSlotToStorageRequested += OnAutoStackSelectedSlotToStorageRequested;
+ _generalGrid.AutoStackSelectedSlotToStarshipRequested += OnAutoStackSelectedSlotToStarshipRequested;
+ _generalGrid.AutoStackSelectedSlotToFreighterRequested += OnAutoStackSelectedSlotToFreighterRequested;
+ _generalGrid.RefreshToolbarActions();
+ _techGrid.RefreshToolbarActions();
var cfg = ExportConfig.Instance;
_generalGrid.SetExportFileName($"exosuit_cargo_inv{cfg.ExosuitExt}");
_techGrid.SetExportFileName($"exosuit_tech_inv{cfg.ExosuitExt}");
diff --git a/UI/Panels/ExosuitPanel.cs b/UI/Panels/ExosuitPanel.cs
index d24eff4a..fae70b34 100644
--- a/UI/Panels/ExosuitPanel.cs
+++ b/UI/Panels/ExosuitPanel.cs
@@ -1,16 +1,24 @@
using NMSE.Data;
using NMSE.Models;
using NMSE.Core;
+using NMSE.Config;
namespace NMSE.UI.Panels;
public partial class ExosuitPanel : UserControl
{
private JsonObject? _playerState;
+ private string _saveScopeKey = "unknown";
/// Raised when inventory data is modified by the user.
public event EventHandler? DataModified;
+ ///
+ /// Raised after auto-stack moves cargo into another inventory so destination
+ /// panels can refresh their grids immediately.
+ ///
+ public event EventHandler? CrossInventoryTransferCompleted;
+
public ExosuitPanel()
{
InitializeComponent();
@@ -29,6 +37,12 @@ public void SetIconManager(IconManager? iconManager)
_techGrid.SetIconManager(iconManager);
}
+ public void SetSaveScopeKey(string saveScopeKey)
+ {
+ _saveScopeKey = string.IsNullOrWhiteSpace(saveScopeKey) ? "unknown" : saveScopeKey;
+ ApplyPinnedSlots();
+ }
+
public void LoadData(JsonObject saveData)
{
try
@@ -38,6 +52,7 @@ public void LoadData(JsonObject saveData)
_generalGrid.LoadInventory(_playerState.GetObject(ExosuitLogic.CargoInventoryKey));
_techGrid.LoadInventory(_playerState.GetObject(ExosuitLogic.TechInventoryKey));
+ ApplyPinnedSlots();
}
catch { /* Save structure varies between versions */ }
}
@@ -55,6 +70,166 @@ public void SaveData(JsonObject saveData)
catch { }
}
+ private void OnAutoStackToStorageRequested(object? sender, EventArgs e)
+ {
+ if (_playerState == null) return;
+
+ var cargoInventory = _generalGrid.GetLoadedInventory() ?? _playerState.GetObject(ExosuitLogic.CargoInventoryKey);
+ if (cargoInventory == null) return;
+
+ var pinned = new HashSet<(int x, int y)>(_generalGrid.GetPinnedSlots());
+
+ bool changed = ExosuitAutoStackLogic.AutoStackCargoToChests(cargoInventory, _playerState, out _, out _, pinned);
+ if (!changed) return;
+
+ _generalGrid.LoadInventory(cargoInventory);
+ DataModified?.Invoke(this, EventArgs.Empty);
+ CrossInventoryTransferCompleted?.Invoke(this, EventArgs.Empty);
+ }
+
+ private void OnAutoStackToStarshipRequested(object? sender, EventArgs e)
+ {
+ if (_playerState == null) return;
+
+ var cargoInventory = _generalGrid.GetLoadedInventory() ?? _playerState.GetObject(ExosuitLogic.CargoInventoryKey);
+ if (cargoInventory == null) return;
+
+ var pinned = new HashSet<(int x, int y)>(_generalGrid.GetPinnedSlots());
+
+ bool changed = ExosuitAutoStackLogic.AutoStackCargoToStarship(cargoInventory, _playerState, out _, out _, pinned);
+ if (!changed) return;
+
+ _generalGrid.LoadInventory(cargoInventory);
+ DataModified?.Invoke(this, EventArgs.Empty);
+ CrossInventoryTransferCompleted?.Invoke(this, EventArgs.Empty);
+ }
+
+ private void OnAutoStackToFreighterRequested(object? sender, EventArgs e)
+ {
+ if (_playerState == null) return;
+
+ var cargoInventory = _generalGrid.GetLoadedInventory() ?? _playerState.GetObject(ExosuitLogic.CargoInventoryKey);
+ if (cargoInventory == null) return;
+
+ var pinned = new HashSet<(int x, int y)>(_generalGrid.GetPinnedSlots());
+
+ bool changed = ExosuitAutoStackLogic.AutoStackCargoToFreighter(cargoInventory, _playerState, out _, out _, pinned);
+ if (!changed) return;
+
+ _generalGrid.LoadInventory(cargoInventory);
+ DataModified?.Invoke(this, EventArgs.Empty);
+ CrossInventoryTransferCompleted?.Invoke(this, EventArgs.Empty);
+ }
+
+ private void OnAutoStackSelectedSlotToStorageRequested(object? sender, InventoryGridPanel.AutoStackSlotRequestEventArgs e)
+ {
+ if (!TryGetContextAutoStackCargo(out var cargoInventory, out var pinned, e, out var sourceSlotFilter, out var sourceItemIdFilter))
+ return;
+
+ bool changed = ExosuitAutoStackLogic.AutoStackCargoToChests(
+ cargoInventory,
+ _playerState!,
+ out _,
+ out _,
+ pinned,
+ sourceSlotFilter,
+ sourceItemIdFilter);
+
+ if (!changed) return;
+
+ _generalGrid.LoadInventory(cargoInventory);
+ DataModified?.Invoke(this, EventArgs.Empty);
+ CrossInventoryTransferCompleted?.Invoke(this, EventArgs.Empty);
+ }
+
+ private void OnAutoStackSelectedSlotToStarshipRequested(object? sender, InventoryGridPanel.AutoStackSlotRequestEventArgs e)
+ {
+ if (!TryGetContextAutoStackCargo(out var cargoInventory, out var pinned, e, out var sourceSlotFilter, out var sourceItemIdFilter))
+ return;
+
+ bool changed = ExosuitAutoStackLogic.AutoStackCargoToStarship(
+ cargoInventory,
+ _playerState!,
+ out _,
+ out _,
+ pinned,
+ sourceSlotFilter,
+ sourceItemIdFilter);
+
+ if (!changed) return;
+
+ _generalGrid.LoadInventory(cargoInventory);
+ DataModified?.Invoke(this, EventArgs.Empty);
+ CrossInventoryTransferCompleted?.Invoke(this, EventArgs.Empty);
+ }
+
+ private void OnAutoStackSelectedSlotToFreighterRequested(object? sender, InventoryGridPanel.AutoStackSlotRequestEventArgs e)
+ {
+ if (!TryGetContextAutoStackCargo(out var cargoInventory, out var pinned, e, out var sourceSlotFilter, out var sourceItemIdFilter))
+ return;
+
+ bool changed = ExosuitAutoStackLogic.AutoStackCargoToFreighter(
+ cargoInventory,
+ _playerState!,
+ out _,
+ out _,
+ pinned,
+ sourceSlotFilter,
+ sourceItemIdFilter);
+
+ if (!changed) return;
+
+ _generalGrid.LoadInventory(cargoInventory);
+ DataModified?.Invoke(this, EventArgs.Empty);
+ CrossInventoryTransferCompleted?.Invoke(this, EventArgs.Empty);
+ }
+
+ private bool TryGetContextAutoStackCargo(
+ out JsonObject cargoInventory,
+ out HashSet<(int x, int y)> pinned,
+ InventoryGridPanel.AutoStackSlotRequestEventArgs request,
+ out (int x, int y) sourceSlotFilter,
+ out string sourceItemIdFilter)
+ {
+ cargoInventory = null!;
+ pinned = null!;
+ sourceSlotFilter = default;
+ sourceItemIdFilter = request.ItemId;
+
+ if (_playerState == null)
+ return false;
+
+ cargoInventory = _generalGrid.GetLoadedInventory() ?? _playerState.GetObject(ExosuitLogic.CargoInventoryKey)!;
+ if (cargoInventory == null)
+ return false;
+
+ pinned = new HashSet<(int x, int y)>(_generalGrid.GetPinnedSlots());
+ sourceSlotFilter = (request.X, request.Y);
+
+ if (pinned.Contains(sourceSlotFilter))
+ {
+ MessageBox.Show(
+ UiStrings.Get("inventory.auto_stack_pinned_slot_blocked"),
+ UiStrings.Get("dialog.info"),
+ MessageBoxButtons.OK,
+ MessageBoxIcon.Information);
+ return false;
+ }
+
+ return true;
+ }
+
+ private void ApplyPinnedSlots()
+ {
+ var pinned = AppConfig.Instance.GetPinnedSlots(_saveScopeKey, "ExosuitCargo");
+ _generalGrid.SetPinnedSlots(pinned);
+ }
+
+ private void OnPinnedSlotsChanged(object? sender, EventArgs e)
+ {
+ AppConfig.Instance.SetPinnedSlots(_saveScopeKey, "ExosuitCargo", _generalGrid.GetPinnedSlots());
+ }
+
public void ApplyUiLocalisation()
{
_titleLabel.Text = UiStrings.Get("exosuit.title");
diff --git a/UI/Panels/FreighterPanel.Designer.cs b/UI/Panels/FreighterPanel.Designer.cs
index 687d5a52..c9c7f4a9 100644
--- a/UI/Panels/FreighterPanel.Designer.cs
+++ b/UI/Panels/FreighterPanel.Designer.cs
@@ -641,6 +641,7 @@ private void SetupLayout()
_techGrid.SetIsTechInventory(true);
_generalGrid.SetIsCargoInventory(true);
+ _generalGrid.SetSortingEnabled(true);
_techGrid.SetInventoryOwnerType("Freighter");
_generalGrid.SetInventoryOwnerType("Freighter");
_generalGrid.SetInventoryGroup("FreighterCargo");
diff --git a/UI/Panels/InventoryGridPanel.Designer.cs b/UI/Panels/InventoryGridPanel.Designer.cs
index 88ed3fc0..39596b98 100644
--- a/UI/Panels/InventoryGridPanel.Designer.cs
+++ b/UI/Panels/InventoryGridPanel.Designer.cs
@@ -50,8 +50,17 @@ private void SetupLayout()
splitContainer.SplitterDistance = splitContainer.Width - 290;
};
+ // Left: info row above toolbar/grid for long inventory guidance text
+ _infoPanel = new Panel
+ {
+ Dock = DockStyle.Top,
+ Height = 26,
+ Padding = new Padding(4, 4, 4, 0),
+ Visible = false
+ };
+
// Left: resize controls above the grid
- var resizePanel = new FlowLayoutPanel
+ _toolbarPanel = new FlowLayoutPanel
{
Dock = DockStyle.Top,
Height = 36,
@@ -68,17 +77,42 @@ private void SetupLayout()
_resizeHeight = new NumericUpDown { Minimum = 1, Maximum = 20, Value = 6, Width = 50, Dock = DockStyle.Left };
_resizeButton = new Button { Text = "Resize", AutoSize = false, Size = new Size(75, 28), MinimumSize = new Size(75, 28), Margin = new Padding(8, 0, 0, 0) };
_resizeButton.Click += OnResizeInventory;
+ _sortModeLabel = new Label { Text = "Sort:", AutoSize = true, Dock = DockStyle.Left, Padding = new Padding(12, 4, 2, 0) };
+ _sortModeCombo = new ComboBox { DropDownStyle = ComboBoxStyle.DropDownList, Width = 130, Margin = new Padding(0, 0, 0, 0) };
+ _sortModeCombo.SelectedIndexChanged += OnSortModeChanged;
+ _autoStackToolStrip = new ToolStrip
+ {
+ AutoSize = false,
+ Size = new Size(130, 28),
+ MinimumSize = new Size(130, 28),
+ GripStyle = ToolStripGripStyle.Hidden,
+ CanOverflow = false,
+ Padding = Padding.Empty,
+ Margin = new Padding(8, 0, 0, 0),
+ RenderMode = ToolStripRenderMode.System
+ };
+ _autoStackDropDownButton = new ToolStripDropDownButton("Auto-Stack");
+ _autoStackToChestsButtonMenuItem = new ToolStripMenuItem("To Chests", null, OnAutoStackToStorage);
+ _autoStackToStarshipButtonMenuItem = new ToolStripMenuItem("To Starship", null, OnAutoStackToStarship);
+ _autoStackToFreighterButtonMenuItem = new ToolStripMenuItem("To Freighter", null, OnAutoStackToFreighter);
+ _autoStackDropDownButton.DropDownItems.Add(_autoStackToChestsButtonMenuItem);
+ _autoStackDropDownButton.DropDownItems.Add(_autoStackToStarshipButtonMenuItem);
+ _autoStackDropDownButton.DropDownItems.Add(_autoStackToFreighterButtonMenuItem);
+ _autoStackToolStrip.Items.Add(_autoStackDropDownButton);
_exportButton = new Button { Text = "Export", AutoSize = false, Size = new Size(75, 28), MinimumSize = new Size(75, 28), Margin = new Padding(16, 0, 0, 0) };
_exportButton.Click += OnExportInventory;
_importButton = new Button { Text = "Import", AutoSize = false, Size = new Size(75, 28), MinimumSize = new Size(75, 28), Margin = new Padding(4, 0, 0, 0) };
_importButton.Click += OnImportInventory;
- resizePanel.Controls.Add(_resizeWidthLabel);
- resizePanel.Controls.Add(_resizeWidth);
- resizePanel.Controls.Add(_resizeHeightLabel);
- resizePanel.Controls.Add(_resizeHeight);
- resizePanel.Controls.Add(_resizeButton);
- resizePanel.Controls.Add(_exportButton);
- resizePanel.Controls.Add(_importButton);
+ _toolbarPanel.Controls.Add(_resizeWidthLabel);
+ _toolbarPanel.Controls.Add(_resizeWidth);
+ _toolbarPanel.Controls.Add(_resizeHeightLabel);
+ _toolbarPanel.Controls.Add(_resizeHeight);
+ _toolbarPanel.Controls.Add(_resizeButton);
+ _toolbarPanel.Controls.Add(_sortModeLabel);
+ _toolbarPanel.Controls.Add(_sortModeCombo);
+ _toolbarPanel.Controls.Add(_autoStackToolStrip);
+ _toolbarPanel.Controls.Add(_exportButton);
+ _toolbarPanel.Controls.Add(_importButton);
// Note: Dock=Left controls are added in reverse visual order
// Left: grid of slot cells
@@ -89,7 +123,8 @@ private void SetupLayout()
BackColor = Color.FromArgb(30, 30, 30)
};
splitContainer.Panel1.Controls.Add(_gridContainer);
- splitContainer.Panel1.Controls.Add(resizePanel);
+ splitContainer.Panel1.Controls.Add(_toolbarPanel);
+ splitContainer.Panel1.Controls.Add(_infoPanel);
// Right: detail/editor panel
_detailPanel = new Panel
@@ -332,6 +367,7 @@ private void SetupLayout()
_removeItemMenuItem = new ToolStripMenuItem("Remove Item", null, OnRemoveItem);
_enableSlotMenuItem = new ToolStripMenuItem("Enable/Disable Slot", null, OnEnableSlot);
_enableAllSlotsMenuItem = new ToolStripMenuItem("Enable All Slots", null, OnEnableAllSlots);
+ _pinSlotMenuItem = new ToolStripMenuItem("Pin Slot", null, OnTogglePinnedSlot);
_repairSlotMenuItem = new ToolStripMenuItem("Repair Slot", null, OnRepairSlot);
_repairAllSlotsMenuItem = new ToolStripMenuItem("Repair All Slots", null, OnRepairAllSlots);
_superchargeSlotMenuItem = new ToolStripMenuItem("Supercharge Slot", null, OnSuperchargeSlot);
@@ -341,11 +377,17 @@ private void SetupLayout()
_refillAllStacksMenuItem = new ToolStripMenuItem("Refill All Stacks", null, OnRefillAllStacks);
_copyItemMenuItem = new ToolStripMenuItem("Copy Item", null, OnCopyItem);
_pasteItemMenuItem = new ToolStripMenuItem("Paste Item", null, OnPasteItem);
+ _sortByNameMenuItem = new ToolStripMenuItem("Sort by Name", null, OnSortByName);
+ _sortByCategoryMenuItem = new ToolStripMenuItem("Sort by Category", null, OnSortByCategory);
+ _autoStackToStorageMenuItem = new ToolStripMenuItem("Auto-Stack to Chests", null, OnAutoStackToStorage);
+ _autoStackToStarshipMenuItem = new ToolStripMenuItem("Auto-Stack to Starship", null, OnAutoStackToStarship);
+ _autoStackToFreighterMenuItem = new ToolStripMenuItem("Auto-Stack to Freighter", null, OnAutoStackToFreighter);
_cellContextMenu.Items.Add(_addItemMenuItem);
_cellContextMenu.Items.Add(_removeItemMenuItem);
_cellContextMenu.Items.Add(new ToolStripSeparator());
_cellContextMenu.Items.Add(_enableSlotMenuItem);
_cellContextMenu.Items.Add(_enableAllSlotsMenuItem);
+ _cellContextMenu.Items.Add(_pinSlotMenuItem);
_cellContextMenu.Items.Add(new ToolStripSeparator());
_cellContextMenu.Items.Add(_repairSlotMenuItem);
_cellContextMenu.Items.Add(_repairAllSlotsMenuItem);
@@ -359,6 +401,12 @@ private void SetupLayout()
_cellContextMenu.Items.Add(new ToolStripSeparator());
_cellContextMenu.Items.Add(_copyItemMenuItem);
_cellContextMenu.Items.Add(_pasteItemMenuItem);
+ _cellContextMenu.Items.Add(new ToolStripSeparator());
+ _cellContextMenu.Items.Add(_sortByNameMenuItem);
+ _cellContextMenu.Items.Add(_sortByCategoryMenuItem);
+ _cellContextMenu.Items.Add(_autoStackToStorageMenuItem);
+ _cellContextMenu.Items.Add(_autoStackToStarshipMenuItem);
+ _cellContextMenu.Items.Add(_autoStackToFreighterMenuItem);
_cellContextMenu.Opening += OnContextMenuOpening;
@@ -369,6 +417,8 @@ private void SetupLayout()
}
// Grid area
+ private Panel _infoPanel = null!;
+ private FlowLayoutPanel _toolbarPanel = null!;
private Panel _gridContainer = null!;
// Detail/editor panel controls
@@ -412,6 +462,13 @@ private void SetupLayout()
private Label _resizeWidthLabel = null!;
private Label _resizeHeightLabel = null!;
private Button _resizeButton = null!;
+ private Label _sortModeLabel = null!;
+ private ComboBox _sortModeCombo = null!;
+ private ToolStrip _autoStackToolStrip = null!;
+ private ToolStripDropDownButton _autoStackDropDownButton = null!;
+ private ToolStripMenuItem _autoStackToChestsButtonMenuItem = null!;
+ private ToolStripMenuItem _autoStackToStarshipButtonMenuItem = null!;
+ private ToolStripMenuItem _autoStackToFreighterButtonMenuItem = null!;
private Button _importButton = null!;
private Button _exportButton = null!;
@@ -421,6 +478,7 @@ private void SetupLayout()
private ToolStripMenuItem _removeItemMenuItem = null!;
private ToolStripMenuItem _enableSlotMenuItem = null!;
private ToolStripMenuItem _enableAllSlotsMenuItem = null!;
+ private ToolStripMenuItem _pinSlotMenuItem = null!;
private ToolStripMenuItem _repairSlotMenuItem = null!;
private ToolStripMenuItem _repairAllSlotsMenuItem = null!;
private ToolStripMenuItem _superchargeSlotMenuItem = null!;
@@ -430,4 +488,9 @@ private void SetupLayout()
private ToolStripMenuItem _refillAllStacksMenuItem = null!;
private ToolStripMenuItem _copyItemMenuItem = null!;
private ToolStripMenuItem _pasteItemMenuItem = null!;
+ private ToolStripMenuItem _sortByNameMenuItem = null!;
+ private ToolStripMenuItem _sortByCategoryMenuItem = null!;
+ private ToolStripMenuItem _autoStackToStorageMenuItem = null!;
+ private ToolStripMenuItem _autoStackToStarshipMenuItem = null!;
+ private ToolStripMenuItem _autoStackToFreighterMenuItem = null!;
}
diff --git a/UI/Panels/InventoryGridPanel.cs b/UI/Panels/InventoryGridPanel.cs
index 9c98a422..e5d99310 100644
--- a/UI/Panels/InventoryGridPanel.cs
+++ b/UI/Panels/InventoryGridPanel.cs
@@ -15,6 +15,35 @@ namespace NMSE.UI.Panels;
///
public partial class InventoryGridPanel : UserControl
{
+ public sealed class AutoStackSlotRequestEventArgs : EventArgs
+ {
+ public AutoStackSlotRequestEventArgs(int x, int y, string itemId)
+ {
+ X = x;
+ Y = y;
+ ItemId = itemId;
+ }
+
+ public int X { get; }
+ public int Y { get; }
+ public string ItemId { get; }
+ }
+
+ private enum InventorySortMode
+ {
+ None,
+ Name,
+ Category,
+ }
+
+ private sealed class SortModeOption
+ {
+ public required InventorySortMode Mode { get; init; }
+ public required string Label { get; init; }
+
+ public override string ToString() => Label;
+ }
+
// Cells / grid items
private const int GridColumns = 10;
private const int CellWidth = 72;
@@ -71,11 +100,64 @@ public void SetInventoryGroup(string group)
_inventoryGroup = group;
}
+ private bool _pinSlotFeatureEnabled;
+ private readonly HashSet<(int x, int y)> _pinnedSlots = new();
+
+ ///
+ /// Raised when pinned-slot coordinates change.
+ ///
+ public event EventHandler? PinnedSlotsChanged;
+
+ public void SetPinSlotFeatureEnabled(bool enabled)
+ {
+ _pinSlotFeatureEnabled = enabled;
+ UpdatePinnedVisuals();
+ }
+
+ public void SetPinnedSlots(IEnumerable<(int x, int y)> pinnedSlots)
+ {
+ _pinnedSlots.Clear();
+ foreach (var pos in pinnedSlots)
+ _pinnedSlots.Add(pos);
+ UpdatePinnedVisuals();
+ }
+
+ public IReadOnlyCollection<(int x, int y)> GetPinnedSlots() => _pinnedSlots.ToArray();
+
+ private bool IsPinnedSlot(int x, int y) => _pinnedSlots.Contains((x, y));
+
+ private void RaisePinnedSlotsChanged() => PinnedSlotsChanged?.Invoke(this, EventArgs.Empty);
+
+ private void UpdatePinnedVisuals()
+ {
+ if (_cells.Count == 0)
+ return;
+
+ foreach (var cell in _cells)
+ {
+ cell.ShowPinToggle = _pinSlotFeatureEnabled && cell.IsActivated;
+ cell.IsPinnedForAutoStack = cell.IsActivated && IsPinnedSlot(cell.GridX, cell.GridY);
+ cell.UpdateDisplay();
+ }
+ }
+
+ private bool _sortingEnabled;
+ private InventorySortMode _currentSortMode;
+ private bool _suppressSortModeEvents;
+ private bool _isApplyingSort;
+
+ public void SetSortingEnabled(bool enabled)
+ {
+ _sortingEnabled = enabled;
+ UpdateToolbarActionVisibility();
+ }
+
// Identify storages that can't be resized
private bool _isStorageInventory = false;
public void SetIsStorageInventory(bool isStorage)
{
_isStorageInventory = isStorage;
+ UpdateToolbarActionVisibility();
}
// Identify chest inventories that can be resized (up to 10x12)
@@ -103,6 +185,7 @@ public void SetIsTechInventory(bool isTech)
public void SetIsCargoInventory(bool isCargo)
{
_isCargoInventory = isCargo;
+ UpdateToolbarActionVisibility();
}
// Owner type for TechnologyCategory-based item filtering.
@@ -206,6 +289,43 @@ public void SetSuperchargeConstraints(int maxSlots, int maxRow)
public event EventHandler? DataModified;
private void RaiseDataModified() => DataModified?.Invoke(this, EventArgs.Empty);
+ ///
+ /// Raised when the user requests moving Exosuit cargo items into matching chest stacks.
+ /// The parent panel executes the cross-inventory transfer.
+ ///
+ public event EventHandler? AutoStackToStorageRequested;
+
+ ///
+ /// Raised when the user requests moving Exosuit cargo items into the current Starship cargo inventory.
+ ///
+ public event EventHandler? AutoStackToStarshipRequested;
+
+ ///
+ /// Raised when the user requests moving Exosuit cargo items into the Freighter cargo inventory.
+ ///
+ public event EventHandler? AutoStackToFreighterRequested;
+
+ ///
+ /// Raised when the user requests a context-menu auto-stack operation for the selected slot to chests.
+ ///
+ public event EventHandler? AutoStackSelectedSlotToStorageRequested;
+
+ ///
+ /// Raised when the user requests a context-menu auto-stack operation for the selected slot to starship.
+ ///
+ public event EventHandler? AutoStackSelectedSlotToStarshipRequested;
+
+ ///
+ /// Raised when the user requests a context-menu auto-stack operation for the selected slot to freighter.
+ ///
+ public event EventHandler? AutoStackSelectedSlotToFreighterRequested;
+
+ public void RefreshToolbarActions()
+ {
+ UpdateToolbarActionVisibility();
+ UpdateToolbarActionEnabledState();
+ }
+
///
/// Sets the default filename used when exporting this inventory.
///
@@ -343,24 +463,25 @@ public void SetMaxSupportedLabel(string text)
_maxSupportedLabel = new Label
{
Text = text,
- AutoSize = false,
+ AutoSize = true,
ForeColor = Color.Red,
TextAlign = ContentAlignment.MiddleLeft,
Font = new Font("Segoe UI", 9F, FontStyle.Bold),
- Margin = new Padding(12, 0, 0, 0),
- Height = 28,
- MinimumSize = new Size(160, 28), // adjust width as needed
- Anchor = AnchorStyles.Left | AnchorStyles.Top
+ Dock = DockStyle.Left,
+ Padding = new Padding(0, 2, 0, 0)
};
- var resizePanel = _resizeButton.Parent as FlowLayoutPanel;
- if (resizePanel != null)
+
+ if (_infoPanel != null)
{
- resizePanel.Controls.Add(_maxSupportedLabel);
+ _infoPanel.Controls.Add(_maxSupportedLabel);
+ _infoPanel.Visible = true;
}
}
else
{
_maxSupportedLabel.Text = text;
+ if (_infoPanel != null)
+ _infoPanel.Visible = !string.IsNullOrWhiteSpace(text);
}
}
@@ -368,6 +489,8 @@ public InventoryGridPanel()
{
InitializeComponent();
SetupLayout();
+ PopulateSortModeOptions();
+ RefreshToolbarActions();
}
private void DisableControlsOnInit()
@@ -384,6 +507,96 @@ private void DisableControlsOnInit()
_typeFilter.Enabled = false;
_categoryFilter.Enabled = false;
_itemPicker.Enabled = false;
+ UpdateToolbarActionEnabledState();
+ }
+
+ private void PopulateSortModeOptions()
+ {
+ _suppressSortModeEvents = true;
+ _sortModeCombo.BeginUpdate();
+ _sortModeCombo.Items.Clear();
+ _sortModeCombo.Items.Add(new SortModeOption { Mode = InventorySortMode.None, Label = UiStrings.Get("inventory.sort_none") });
+ _sortModeCombo.Items.Add(new SortModeOption { Mode = InventorySortMode.Name, Label = UiStrings.Get("inventory.sort_name") });
+ _sortModeCombo.Items.Add(new SortModeOption { Mode = InventorySortMode.Category, Label = UiStrings.Get("inventory.sort_category") });
+ _sortModeCombo.SelectedIndex = (int)_currentSortMode;
+ _sortModeCombo.EndUpdate();
+ _suppressSortModeEvents = false;
+ }
+
+ private void UpdateToolbarActionVisibility()
+ {
+ bool showSortControls = _sortingEnabled;
+ bool showAutoStackControl = _isCargoInventory && !_isStorageInventory
+ && (AutoStackToStorageRequested != null || AutoStackToStarshipRequested != null || AutoStackToFreighterRequested != null);
+
+ _sortModeLabel.Visible = showSortControls;
+ _sortModeCombo.Visible = showSortControls;
+ _autoStackToolStrip.Visible = showAutoStackControl;
+
+ _autoStackToChestsButtonMenuItem.Visible = AutoStackToStorageRequested != null;
+ _autoStackToStarshipButtonMenuItem.Visible = AutoStackToStarshipRequested != null;
+ _autoStackToFreighterButtonMenuItem.Visible = AutoStackToFreighterRequested != null;
+ }
+
+ private void UpdateToolbarActionEnabledState()
+ {
+ bool inventoryLoaded = _currentInventory != null;
+ _sortModeCombo.Enabled = _sortingEnabled && inventoryLoaded;
+ _autoStackToolStrip.Enabled = _isCargoInventory && !_isStorageInventory
+ && (AutoStackToStorageRequested != null || AutoStackToStarshipRequested != null || AutoStackToFreighterRequested != null)
+ && inventoryLoaded;
+
+ _autoStackToChestsButtonMenuItem.Enabled = AutoStackToStorageRequested != null && inventoryLoaded;
+ _autoStackToStarshipButtonMenuItem.Enabled = AutoStackToStarshipRequested != null && inventoryLoaded;
+ _autoStackToFreighterButtonMenuItem.Enabled = AutoStackToFreighterRequested != null && inventoryLoaded;
+ }
+
+ private void SetSortMode(InventorySortMode mode, bool applySort, bool raiseModified)
+ {
+ _currentSortMode = mode;
+
+ if (_sortModeCombo.SelectedIndex != (int)mode)
+ {
+ _suppressSortModeEvents = true;
+ _sortModeCombo.SelectedIndex = (int)mode;
+ _suppressSortModeEvents = false;
+ }
+
+ if (!applySort)
+ return;
+
+ ApplyCurrentSortMode(raiseModified);
+ }
+
+ private void ApplyCurrentSortMode(bool raiseModified)
+ {
+ switch (_currentSortMode)
+ {
+ case InventorySortMode.Name:
+ SortInventory(CompareByName, raiseModified);
+ break;
+ case InventorySortMode.Category:
+ SortInventory(CompareByCategory, raiseModified);
+ break;
+ }
+ }
+
+ private static int CompareByName(SlotSortEntry a, SlotSortEntry b)
+ {
+ int byName = string.Compare(a.SortName, b.SortName, StringComparison.OrdinalIgnoreCase);
+ if (byName != 0) return byName;
+ int byCategory = string.Compare(a.SortCategory, b.SortCategory, StringComparison.OrdinalIgnoreCase);
+ if (byCategory != 0) return byCategory;
+ return string.Compare(a.ItemId, b.ItemId, StringComparison.OrdinalIgnoreCase);
+ }
+
+ private static int CompareByCategory(SlotSortEntry a, SlotSortEntry b)
+ {
+ int byCategory = string.Compare(a.SortCategory, b.SortCategory, StringComparison.OrdinalIgnoreCase);
+ if (byCategory != 0) return byCategory;
+ int byName = string.Compare(a.SortName, b.SortName, StringComparison.OrdinalIgnoreCase);
+ if (byName != 0) return byName;
+ return string.Compare(a.ItemId, b.ItemId, StringComparison.OrdinalIgnoreCase);
}
private static Label CreateLabel(string text) =>
@@ -410,6 +623,11 @@ public void SetIconManager(IconManager? iconManager)
_iconManager = iconManager;
}
+ public JsonObject? GetLoadedInventory()
+ {
+ return _currentInventory;
+ }
+
private void PopulateTypeFilter()
{
_suppressFilterEvents = true;
@@ -662,6 +880,7 @@ private void EnableControlsAfterInventoryLoad()
_typeFilter.Enabled = true;
_categoryFilter.Enabled = true;
_itemPicker.Enabled = true;
+ UpdateToolbarActionEnabledState();
}
public void LoadInventory(JsonObject? inventory)
@@ -685,15 +904,17 @@ public void LoadInventory(JsonObject? inventory)
cell.Dispose();
}
_gridContainer.Controls.Clear();
- EnableControlsAfterInventoryLoad();
_cells.Clear();
_selectedCell = null;
ClearDetailPanel();
_slots = null;
_currentInventory = inventory;
+ EnableControlsAfterInventoryLoad();
+
if (inventory == null)
{
+ UpdateToolbarActionEnabledState();
_gridContainer.ResumeLayout(false);
ResumeLayout(true);
RedrawHelper.Resume(_gridContainer);
@@ -702,6 +923,7 @@ public void LoadInventory(JsonObject? inventory)
_slots = inventory.GetArray("Slots");
if (_slots == null)
{
+ UpdateToolbarActionEnabledState();
_gridContainer.ResumeLayout(false);
ResumeLayout(true);
RedrawHelper.Resume(_gridContainer);
@@ -842,6 +1064,7 @@ public void LoadInventory(JsonObject? inventory)
col * (CellWidth + CellPadding) + CellPadding,
r * (CellHeight + CellPadding) + CellPadding
);
+ cell.ShowPinToggle = _pinSlotFeatureEnabled;
if (slotMap.TryGetValue((col, r), out var slotData))
{
@@ -855,6 +1078,7 @@ public void LoadInventory(JsonObject? inventory)
cell.IsValidEmpty = true;
cell.IsActivated = true; // In ValidSlotIndices = enabled
cell.IsSupercharged = IsSlotSupercharged(col, r);
+ cell.IsPinnedForAutoStack = IsPinnedSlot(col, r);
cell.UpdateDisplay();
}
else
@@ -862,10 +1086,12 @@ public void LoadInventory(JsonObject? inventory)
// Not in ValidSlotIndices and no data = disabled slot
cell.IsEmpty = true;
cell.IsActivated = false;
+ cell.IsPinnedForAutoStack = false;
cell.UpdateDisplay();
}
cell.Click += OnCellClicked;
+ cell.PinToggleClicked += OnCellPinToggleClicked;
AttachRightClickHandler(cell);
AttachDragHandlers(cell);
cellsToAdd[cellIdx++] = cell;
@@ -892,6 +1118,10 @@ public void LoadInventory(JsonObject? inventory)
// then re-enable painting for one flicker-free repaint of the whole grid.
ResumeLayout(true);
RedrawHelper.Resume(_gridContainer);
+ UpdateToolbarActionEnabledState();
+
+ if (_sortingEnabled && _currentSortMode != InventorySortMode.None && !_isApplyingSort)
+ ApplyCurrentSortMode(false);
}
///
@@ -936,6 +1166,9 @@ private static string BinaryDataToItemId(BinaryData data)
///
private static string ExtractItemId(object? rawId)
{
+ if (rawId is JsonObject idObject)
+ rawId = idObject.Get("Id");
+
if (rawId is BinaryData binData)
return BinaryDataToItemId(binData);
return rawId as string ?? "";
@@ -1073,6 +1306,8 @@ private void LoadCellData(SlotCell cell)
// Check activation status (whether position is in ValidSlotIndices)
cell.IsActivated = IsSlotInValidIndices(cell.GridX, cell.GridY);
+ cell.ShowPinToggle = _pinSlotFeatureEnabled && cell.IsActivated;
+ cell.IsPinnedForAutoStack = cell.IsActivated && IsPinnedSlot(cell.GridX, cell.GridY);
// Check supercharged status (SpecialSlots entry with matching X,Y and TechBonus type)
cell.IsSupercharged = IsSlotSupercharged(cell.GridX, cell.GridY);
@@ -1737,6 +1972,8 @@ private void ConfigureContextMenuItems(SlotCell cell)
_enableSlotMenuItem.Visible = !_slotToggleDisabled;
_enableSlotMenuItem.Text = isActivated ? UiStrings.Get("inventory.ctx_disable_slot") : UiStrings.Get("inventory.ctx_enable_slot");
_enableAllSlotsMenuItem.Visible = !_slotToggleDisabled && _currentInventory != null;
+ _pinSlotMenuItem.Visible = _pinSlotFeatureEnabled && isActivated;
+ _pinSlotMenuItem.Text = cell.IsPinnedForAutoStack ? UiStrings.Get("inventory.ctx_unpin_slot") : UiStrings.Get("inventory.ctx_pin_slot");
_repairSlotMenuItem.Visible = hasItem;
_repairAllSlotsMenuItem.Visible = _currentInventory != null;
@@ -1753,6 +1990,13 @@ private void ConfigureContextMenuItems(SlotCell cell)
// Show "Refill All Stacks" only in non-tech (cargo) inventories
_refillAllStacksMenuItem.Visible = !_isTechInventory && _currentInventory != null;
+ _sortByNameMenuItem.Visible = false;
+ _sortByCategoryMenuItem.Visible = false;
+ bool canAutoStack = _isCargoInventory && !_isStorageInventory && _currentInventory != null;
+ _autoStackToStorageMenuItem.Visible = canAutoStack && (AutoStackToStorageRequested != null || AutoStackSelectedSlotToStorageRequested != null);
+ _autoStackToStarshipMenuItem.Visible = canAutoStack && (AutoStackToStarshipRequested != null || AutoStackSelectedSlotToStarshipRequested != null);
+ _autoStackToFreighterMenuItem.Visible = canAutoStack && (AutoStackToFreighterRequested != null || AutoStackSelectedSlotToFreighterRequested != null);
+
_copyItemMenuItem.Visible = cell.SlotData != null && !string.IsNullOrEmpty(cell.ItemId) && !cell.IsValidEmpty;
_pasteItemMenuItem.Visible = _copiedItemCell != null && (cell.IsValidEmpty || !cell.IsEmpty);
}
@@ -1766,6 +2010,34 @@ private void OnContextMenuOpening(object? sender, CancelEventArgs e)
}
}
+ private void OnCellPinToggleClicked(object? sender, EventArgs e)
+ {
+ if (sender is not SlotCell cell)
+ return;
+ TogglePinnedSlot(cell);
+ }
+
+ private void OnTogglePinnedSlot(object? sender, EventArgs e)
+ {
+ if (_contextCell == null)
+ return;
+ TogglePinnedSlot(_contextCell);
+ }
+
+ private void TogglePinnedSlot(SlotCell cell)
+ {
+ if (!_pinSlotFeatureEnabled || !cell.IsActivated)
+ return;
+
+ var pos = (cell.GridX, cell.GridY);
+ if (!_pinnedSlots.Add(pos))
+ _pinnedSlots.Remove(pos);
+
+ cell.IsPinnedForAutoStack = IsPinnedSlot(cell.GridX, cell.GridY);
+ cell.UpdateDisplay();
+ RaisePinnedSlotsChanged();
+ }
+
private void SelectCell(SlotCell cell)
{
// Deselect previous
@@ -2251,7 +2523,11 @@ private void OnEnableSlot(object? sender, EventArgs e)
break;
}
}
+ _pinnedSlots.Remove((x, y));
_contextCell.IsActivated = false;
+ _contextCell.ShowPinToggle = false;
+ _contextCell.IsPinnedForAutoStack = false;
+ RaisePinnedSlotsChanged();
}
else
{
@@ -2263,6 +2539,8 @@ private void OnEnableSlot(object? sender, EventArgs e)
_contextCell.IsActivated = true;
_contextCell.IsEmpty = false;
_contextCell.IsValidEmpty = true;
+ _contextCell.ShowPinToggle = _pinSlotFeatureEnabled;
+ _contextCell.IsPinnedForAutoStack = IsPinnedSlot(x, y);
}
_contextCell.UpdateDisplay();
}
@@ -2554,6 +2832,238 @@ private void OnRefillAllStacks(object? sender, EventArgs e)
if (refilled > 0) RaiseDataModified();
}
+ private sealed class SlotSortEntry
+ {
+ public required JsonObject Slot { get; init; }
+ public required string ItemId { get; init; }
+ public required string SortName { get; init; }
+ public required string SortCategory { get; init; }
+ public int OriginalX { get; init; }
+ public int OriginalY { get; init; }
+ }
+
+ private void OnSortModeChanged(object? sender, EventArgs e)
+ {
+ if (_suppressSortModeEvents) return;
+ if (_sortModeCombo.SelectedItem is not SortModeOption option) return;
+
+ _currentSortMode = option.Mode;
+ ApplyCurrentSortMode(true);
+ }
+
+ private void OnSortByName(object? sender, EventArgs e)
+ {
+ SetSortMode(InventorySortMode.Name, applySort: true, raiseModified: true);
+ }
+
+ private void OnSortByCategory(object? sender, EventArgs e)
+ {
+ SetSortMode(InventorySortMode.Category, applySort: true, raiseModified: true);
+ }
+
+ private void OnAutoStackToStorage(object? sender, EventArgs e)
+ {
+ if (ReferenceEquals(sender, _autoStackToStorageMenuItem)
+ && AutoStackSelectedSlotToStorageRequested != null
+ && TryBuildAutoStackSlotRequest(out var requestArgs))
+ {
+ AutoStackSelectedSlotToStorageRequested?.Invoke(this, requestArgs);
+ return;
+ }
+
+ AutoStackToStorageRequested?.Invoke(this, EventArgs.Empty);
+ }
+
+ private void OnAutoStackToStarship(object? sender, EventArgs e)
+ {
+ if (ReferenceEquals(sender, _autoStackToStarshipMenuItem)
+ && AutoStackSelectedSlotToStarshipRequested != null
+ && TryBuildAutoStackSlotRequest(out var requestArgs))
+ {
+ AutoStackSelectedSlotToStarshipRequested?.Invoke(this, requestArgs);
+ return;
+ }
+
+ AutoStackToStarshipRequested?.Invoke(this, EventArgs.Empty);
+ }
+
+ private void OnAutoStackToFreighter(object? sender, EventArgs e)
+ {
+ if (ReferenceEquals(sender, _autoStackToFreighterMenuItem)
+ && AutoStackSelectedSlotToFreighterRequested != null
+ && TryBuildAutoStackSlotRequest(out var requestArgs))
+ {
+ AutoStackSelectedSlotToFreighterRequested?.Invoke(this, requestArgs);
+ return;
+ }
+
+ AutoStackToFreighterRequested?.Invoke(this, EventArgs.Empty);
+ }
+
+ private bool TryBuildAutoStackSlotRequest(out AutoStackSlotRequestEventArgs requestArgs)
+ {
+ requestArgs = null!;
+
+ if (_contextCell == null || _contextCell.SlotData == null || string.IsNullOrEmpty(_contextCell.ItemId) || _contextCell.IsValidEmpty)
+ return false;
+
+ requestArgs = new AutoStackSlotRequestEventArgs(_contextCell.GridX, _contextCell.GridY, _contextCell.ItemId);
+ return true;
+ }
+
+ private void SortInventory(Comparison comparison, bool raiseModified)
+ {
+ if (_currentInventory == null || _slots == null) return;
+
+ _isApplyingSort = true;
+ try
+ {
+ var entries = new List();
+ var unsortedSlots = new List();
+ for (int i = 0; i < _slots.Length; i++)
+ {
+ JsonObject? slot;
+ try { slot = _slots.GetObject(i); }
+ catch { continue; }
+ if (slot == null) continue;
+
+ string itemId;
+ try { itemId = ExtractItemId(slot.Get("Id")); }
+ catch
+ {
+ unsortedSlots.Add(slot);
+ continue;
+ }
+
+ if (string.IsNullOrEmpty(itemId) || itemId == "^" || itemId == "^YOURSLOTITEM")
+ {
+ unsortedSlots.Add(slot);
+ continue;
+ }
+
+ int x = 0;
+ int y = 0;
+ try
+ {
+ var index = slot.GetObject("Index");
+ if (index != null)
+ {
+ x = index.GetInt("X");
+ y = index.GetInt("Y");
+ }
+ }
+ catch { }
+
+ string sortName = itemId;
+ string sortCategory = "";
+ if (_database != null)
+ {
+ var (gameItem, displayName, _, _) = ResolveItemAndDisplayName(itemId);
+ if (gameItem != null)
+ {
+ sortName = string.IsNullOrEmpty(displayName) ? itemId : displayName;
+ sortCategory = gameItem.Category ?? "";
+ }
+ }
+
+ entries.Add(new SlotSortEntry
+ {
+ Slot = slot,
+ ItemId = itemId,
+ SortName = sortName,
+ SortCategory = sortCategory,
+ OriginalX = x,
+ OriginalY = y,
+ });
+ }
+
+ if (entries.Count < 2) return;
+
+ var targetPositions = GetSortablePositions(_currentInventory);
+ if (targetPositions.Count == 0) return;
+
+ targetPositions.Sort((a, b) =>
+ {
+ int byY = a.y.CompareTo(b.y);
+ return byY != 0 ? byY : a.x.CompareTo(b.x);
+ });
+
+ entries.Sort((a, b) =>
+ {
+ int byRule = comparison(a, b);
+ if (byRule != 0) return byRule;
+ int byY = a.OriginalY.CompareTo(b.OriginalY);
+ return byY != 0 ? byY : a.OriginalX.CompareTo(b.OriginalX);
+ });
+
+ int assignCount = Math.Min(entries.Count, targetPositions.Count);
+ for (int i = 0; i < assignCount; i++)
+ InventorySlotHelper.UpdateSlotIndex(entries[i].Slot, targetPositions[i].x, targetPositions[i].y);
+
+ var newSlots = new JsonArray();
+ foreach (var entry in entries)
+ newSlots.Add(entry.Slot);
+ foreach (var unsorted in unsortedSlots)
+ newSlots.Add(unsorted);
+
+ _currentInventory.Set("Slots", newSlots);
+ _slots = newSlots;
+ LoadInventory(_currentInventory);
+ if (raiseModified)
+ RaiseDataModified();
+ }
+ finally
+ {
+ _isApplyingSort = false;
+ }
+ }
+
+ private static List<(int x, int y)> GetSortablePositions(JsonObject inventory)
+ {
+ var positions = new List<(int x, int y)>();
+ var seen = new HashSet<(int x, int y)>();
+
+ try
+ {
+ var validSlots = inventory.GetArray("ValidSlotIndices");
+ if (validSlots != null)
+ {
+ for (int i = 0; i < validSlots.Length; i++)
+ {
+ try
+ {
+ var idx = validSlots.GetObject(i);
+ if (idx == null) continue;
+ var pos = (idx.GetInt("X"), idx.GetInt("Y"));
+ if (seen.Add(pos)) positions.Add(pos);
+ }
+ catch { }
+ }
+ }
+ }
+ catch { }
+
+ if (positions.Count > 0) return positions;
+
+ int width = GridColumns;
+ int height = 1;
+ try
+ {
+ int invWidth = inventory.GetInt("Width");
+ int invHeight = inventory.GetInt("Height");
+ if (invWidth > 0) width = invWidth;
+ if (invHeight > 0) height = invHeight;
+ }
+ catch { }
+
+ for (int y = 0; y < height; y++)
+ {
+ for (int x = 0; x < width; x++)
+ positions.Add((x, y));
+ }
+ return positions;
+ }
+
private void OnCopyItem(object? sender, EventArgs e)
{
if (_contextCell == null || _contextCell.SlotData == null || string.IsNullOrEmpty(_contextCell.ItemId)) return;
@@ -2820,6 +3330,12 @@ public void ApplyUiLocalisation()
_resizeWidthLabel.Text = UiStrings.Get("inventory.width");
_resizeHeightLabel.Text = UiStrings.Get("inventory.height");
_resizeButton.Text = UiStrings.Get("inventory.resize");
+ _sortModeLabel.Text = UiStrings.Get("inventory.toolbar_sort");
+ _autoStackDropDownButton.Text = UiStrings.Get("inventory.toolbar_auto_stack");
+ _autoStackToChestsButtonMenuItem.Text = UiStrings.Get("inventory.toolbar_auto_stack_chests");
+ _autoStackToStarshipButtonMenuItem.Text = UiStrings.Get("inventory.toolbar_auto_stack_starship");
+ _autoStackToFreighterButtonMenuItem.Text = UiStrings.Get("inventory.toolbar_auto_stack_freighter");
+ PopulateSortModeOptions();
// Detail panel labels
_detailAmountLabel.Text = UiStrings.Get("inventory.amount");
@@ -2844,6 +3360,12 @@ public void ApplyUiLocalisation()
_refillAllStacksMenuItem.Text = UiStrings.Get("inventory.ctx_refill_all");
_copyItemMenuItem.Text = UiStrings.Get("inventory.ctx_copy");
_pasteItemMenuItem.Text = UiStrings.Get("inventory.ctx_paste");
+ _sortByNameMenuItem.Text = UiStrings.Get("inventory.ctx_sort_name");
+ _sortByCategoryMenuItem.Text = UiStrings.Get("inventory.ctx_sort_category");
+ _autoStackToStorageMenuItem.Text = UiStrings.Get("inventory.ctx_auto_stack_chests");
+ _autoStackToStarshipMenuItem.Text = UiStrings.Get("inventory.ctx_auto_stack_starship");
+ _autoStackToFreighterMenuItem.Text = UiStrings.Get("inventory.ctx_auto_stack_freighter");
+ _pinSlotMenuItem.Text = UiStrings.Get("inventory.ctx_pin_slot");
// Import/Export buttons
_importButton.Text = UiStrings.Get("common.import");
@@ -3080,6 +3602,7 @@ private class SlotCell : Panel
private readonly PictureBox _iconBox; // inner PictureBox that we offset upward
private readonly MarqueeLabel _nameLabel;
private readonly Label _amountLabel;
+ private readonly Label _pinLabel;
private readonly ToolTip _toolTip;
private Image? _compositeImage; // tracks composite bitmaps we create so only they are disposed
@@ -3132,6 +3655,14 @@ private class SlotCell : Panel
[DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)]
public bool IsChargeable { get; set; }
+ [DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)]
+ public bool ShowPinToggle { get; set; }
+
+ [DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)]
+ public bool IsPinnedForAutoStack { get; set; }
+
+ public event EventHandler? PinToggleClicked;
+
/// Cached corvette-resolved display name. Preserved across drag/drop so
/// the greedy GuessCorvetteBasePart match is not lost when cells move.
[DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)]
@@ -3223,12 +3754,31 @@ public SlotCell(int x, int y, int width, int height, ToolTip sharedToolTip)
AutoEllipsis = true
};
+ _pinLabel = new Label
+ {
+ AutoSize = false,
+ Size = new Size(16, 16),
+ Location = new Point(Math.Max(0, Width - 18), 2),
+ TextAlign = ContentAlignment.MiddleCenter,
+ BackColor = Color.FromArgb(150, 0, 0, 0),
+ ForeColor = Color.Gainsboro,
+ Font = SharedNameFont,
+ Cursor = Cursors.Hand,
+ Visible = false
+ };
+ _pinLabel.Click += (s, e) => PinToggleClicked?.Invoke(this, EventArgs.Empty);
+ _pinLabel.MouseEnter += (s, e) => _pinLabel.ForeColor = Color.White;
+ _pinLabel.MouseLeave += (s, e) => _pinLabel.ForeColor = IsPinnedForAutoStack ? Color.Gold : Color.Gainsboro;
+ Resize += (s, e) => _pinLabel.Location = new Point(Math.Max(0, Width - 18), 2);
+
_toolTip = sharedToolTip;
// Add controls in z-order: amount (bottom), icon container (middle), name (top)
Controls.Add(_amountLabel);
Controls.Add(_iconContainer);
Controls.Add(_nameLabel);
+ Controls.Add(_pinLabel);
+ _pinLabel.BringToFront();
// Forward child clicks to this panel for cell selection
_iconBox.Click += (s, e) => OnClick(e);
@@ -3319,6 +3869,7 @@ public void UpdateDisplay()
string tip = IsActivated ? UiStrings.Format("inventory.tooltip_empty", GridX, GridY) : UiStrings.Format("inventory.tooltip_disabled", GridX, GridY);
_toolTip.SetToolTip(this, tip);
_toolTip.SetToolTip(_iconBox, tip);
+ UpdatePinIndicator();
return;
}
@@ -3347,6 +3898,7 @@ public void UpdateDisplay()
_toolTip.SetToolTip(this, tip);
_toolTip.SetToolTip(_iconBox, tip);
_toolTip.SetToolTip(_nameLabel, tip);
+ UpdatePinIndicator();
return;
}
@@ -3519,6 +4071,22 @@ public void UpdateDisplay()
_toolTip.SetToolTip(_iconBox, tip2);
_toolTip.SetToolTip(_amountLabel, tip2);
_toolTip.SetToolTip(_nameLabel, tip2);
+ UpdatePinIndicator();
+ }
+
+ private void UpdatePinIndicator()
+ {
+ bool show = ShowPinToggle && IsActivated;
+ _pinLabel.Visible = show;
+ if (!show)
+ return;
+
+ _pinLabel.Text = IsPinnedForAutoStack ? "π" : "π";
+ _pinLabel.ForeColor = IsPinnedForAutoStack ? Color.Gold : Color.Gainsboro;
+ _toolTip.SetToolTip(_pinLabel,
+ IsPinnedForAutoStack
+ ? UiStrings.Get("inventory.tooltip_pinned_slot")
+ : UiStrings.Get("inventory.tooltip_unpinned_slot"));
}
protected override void Dispose(bool disposing)
diff --git a/UI/Panels/StarshipPanel.Designer.cs b/UI/Panels/StarshipPanel.Designer.cs
index 1f5170e6..de7644c3 100644
--- a/UI/Panels/StarshipPanel.Designer.cs
+++ b/UI/Panels/StarshipPanel.Designer.cs
@@ -263,12 +263,20 @@ private void SetupLayout()
_techGrid = new InventoryGridPanel { Dock = DockStyle.Fill };
_techGrid.SetIsTechInventory(true);
_inventoryGrid.SetIsCargoInventory(true);
+ _inventoryGrid.SetSortingEnabled(true);
_techGrid.SetInventoryOwnerType("Ship");
_inventoryGrid.SetInventoryOwnerType("Ship");
_inventoryGrid.SetInventoryGroup("ShipCargo");
+ _inventoryGrid.SetPinSlotFeatureEnabled(true);
_techGrid.SetInventoryGroup("Ship");
_inventoryGrid.DataModified += (s, e) => DataModified?.Invoke(this, e);
_techGrid.DataModified += (s, e) => DataModified?.Invoke(this, e);
+ _inventoryGrid.PinnedSlotsChanged += OnPinnedSlotsChanged;
+ _inventoryGrid.AutoStackToStorageRequested += OnAutoStackToStorageRequested;
+ _inventoryGrid.AutoStackToFreighterRequested += OnAutoStackToFreighterRequested;
+ _inventoryGrid.AutoStackSelectedSlotToStorageRequested += OnAutoStackSelectedSlotToStorageRequested;
+ _inventoryGrid.AutoStackSelectedSlotToFreighterRequested += OnAutoStackSelectedSlotToFreighterRequested;
+ _inventoryGrid.RefreshToolbarActions();
_invTabs = new DoubleBufferedTabControl { Dock = DockStyle.Fill };
_cargoTabPage = new TabPage("Cargo");
diff --git a/UI/Panels/StarshipPanel.cs b/UI/Panels/StarshipPanel.cs
index ae9c8cc1..0afe2dec 100644
--- a/UI/Panels/StarshipPanel.cs
+++ b/UI/Panels/StarshipPanel.cs
@@ -1,4 +1,5 @@
using NMSE.Core;
+using NMSE.Config;
using NMSE.Data;
using NMSE.Models;
using NMSE.UI.Util;
@@ -10,11 +11,18 @@ public partial class StarshipPanel : UserControl
/// Raised when inventory data is modified by the user.
public event EventHandler? DataModified;
+ ///
+ /// Raised after auto-stack moves cargo into another inventory so destination
+ /// panels can refresh their grids immediately.
+ ///
+ public event EventHandler? CrossInventoryTransferCompleted;
+
private JsonArray? _shipOwnership;
private JsonObject? _playerState;
private JsonObject? _saveData;
private GameItemDatabase? _database;
private int _primaryShipIndex;
+ private string _saveScopeKey = "unknown";
private readonly Random _rng = new();
/// Raw (unclamped) ship stat values read from JSON for the currently selected ship.
@@ -174,6 +182,12 @@ public void SetIconManager(IconManager? iconManager)
_techGrid.SetIconManager(iconManager);
}
+ public void SetSaveScopeKey(string saveScopeKey)
+ {
+ _saveScopeKey = string.IsNullOrWhiteSpace(saveScopeKey) ? "unknown" : saveScopeKey;
+ ApplyPinnedSlotsForSelectedShip();
+ }
+
public void LoadData(JsonObject saveData)
{
SuspendLayout();
@@ -315,6 +329,7 @@ private void OnShipSelected(object? sender, EventArgs e)
}
_inventoryGrid.LoadInventory(data.Inventory);
+ ApplyPinnedSlotsForSelectedShip();
// Set corvette context for tech grid so CV_ items resolve to actual base parts
bool isCorvette = StarshipLogic.IsCorvette(data.Filename);
@@ -405,6 +420,170 @@ private void OnShipNameChanged(object? sender, EventArgs e)
_shipSelector.SelectedIndexChanged += OnShipSelected;
}
+ private string GetCurrentPinnedInventoryKey()
+ {
+ if (_shipSelector.SelectedIndex < 0)
+ return "StarshipCargo:none";
+
+ var item = (StarshipLogic.ShipListItem)_shipSelector.Items[_shipSelector.SelectedIndex]!;
+ return $"StarshipCargo:{item.DataIndex}";
+ }
+
+ private void ApplyPinnedSlotsForSelectedShip()
+ {
+ if (_shipSelector.SelectedIndex < 0)
+ {
+ _inventoryGrid.SetPinnedSlots([]);
+ return;
+ }
+
+ var pinned = AppConfig.Instance.GetPinnedSlots(_saveScopeKey, GetCurrentPinnedInventoryKey());
+ _inventoryGrid.SetPinnedSlots(pinned);
+ }
+
+ private void OnPinnedSlotsChanged(object? sender, EventArgs e)
+ {
+ if (_shipSelector.SelectedIndex < 0)
+ return;
+
+ AppConfig.Instance.SetPinnedSlots(_saveScopeKey, GetCurrentPinnedInventoryKey(), _inventoryGrid.GetPinnedSlots());
+ }
+
+ private void OnAutoStackToStorageRequested(object? sender, EventArgs e)
+ {
+ if (!TryGetSelectedShipCargoInventory(out var cargoInventory, out _))
+ return;
+
+ var pinned = new HashSet<(int x, int y)>(_inventoryGrid.GetPinnedSlots());
+ bool changed = ExosuitAutoStackLogic.AutoStackCargoToChests(cargoInventory, _playerState!, out _, out _, pinned);
+ if (!changed)
+ return;
+
+ _inventoryGrid.LoadInventory(cargoInventory);
+ DataModified?.Invoke(this, EventArgs.Empty);
+ CrossInventoryTransferCompleted?.Invoke(this, EventArgs.Empty);
+ }
+
+ private void OnAutoStackToFreighterRequested(object? sender, EventArgs e)
+ {
+ if (!TryGetSelectedShipCargoInventory(out var cargoInventory, out _))
+ return;
+
+ if (_playerState?.GetObject("FreighterInventory") is not JsonObject freighterInventory)
+ return;
+
+ var pinned = new HashSet<(int x, int y)>(_inventoryGrid.GetPinnedSlots());
+ bool changed = ExosuitAutoStackLogic.AutoStackFromInventoryToInventory(
+ cargoInventory,
+ freighterInventory,
+ out _,
+ out _,
+ pinned);
+
+ if (!changed)
+ return;
+
+ _inventoryGrid.LoadInventory(cargoInventory);
+ DataModified?.Invoke(this, EventArgs.Empty);
+ CrossInventoryTransferCompleted?.Invoke(this, EventArgs.Empty);
+ }
+
+ private void OnAutoStackSelectedSlotToStorageRequested(object? sender, InventoryGridPanel.AutoStackSlotRequestEventArgs e)
+ {
+ if (!TryGetContextAutoStackCargo(e, out var cargoInventory, out var pinned, out var sourceSlotFilter, out var sourceItemIdFilter))
+ return;
+
+ bool changed = ExosuitAutoStackLogic.AutoStackCargoToChests(
+ cargoInventory,
+ _playerState!,
+ out _,
+ out _,
+ pinned,
+ sourceSlotFilter,
+ sourceItemIdFilter);
+
+ if (!changed)
+ return;
+
+ _inventoryGrid.LoadInventory(cargoInventory);
+ DataModified?.Invoke(this, EventArgs.Empty);
+ CrossInventoryTransferCompleted?.Invoke(this, EventArgs.Empty);
+ }
+
+ private void OnAutoStackSelectedSlotToFreighterRequested(object? sender, InventoryGridPanel.AutoStackSlotRequestEventArgs e)
+ {
+ if (!TryGetContextAutoStackCargo(e, out var cargoInventory, out var pinned, out var sourceSlotFilter, out var sourceItemIdFilter))
+ return;
+
+ if (_playerState?.GetObject("FreighterInventory") is not JsonObject freighterInventory)
+ return;
+
+ bool changed = ExosuitAutoStackLogic.AutoStackFromInventoryToInventory(
+ cargoInventory,
+ freighterInventory,
+ out _,
+ out _,
+ pinned,
+ sourceSlotFilter,
+ sourceItemIdFilter);
+
+ if (!changed)
+ return;
+
+ _inventoryGrid.LoadInventory(cargoInventory);
+ DataModified?.Invoke(this, EventArgs.Empty);
+ CrossInventoryTransferCompleted?.Invoke(this, EventArgs.Empty);
+ }
+
+ private bool TryGetSelectedShipCargoInventory(out JsonObject cargoInventory, out int shipIndex)
+ {
+ cargoInventory = null!;
+ shipIndex = -1;
+
+ if (_shipOwnership == null || _shipSelector.SelectedIndex < 0)
+ return false;
+
+ var item = (StarshipLogic.ShipListItem)_shipSelector.Items[_shipSelector.SelectedIndex]!;
+ shipIndex = item.DataIndex;
+ if (shipIndex < 0 || shipIndex >= _shipOwnership.Length)
+ return false;
+
+ var ship = _shipOwnership.GetObject(shipIndex);
+ cargoInventory = _inventoryGrid.GetLoadedInventory() ?? ship?.GetObject("Inventory")!;
+ return cargoInventory != null;
+ }
+
+ private bool TryGetContextAutoStackCargo(
+ InventoryGridPanel.AutoStackSlotRequestEventArgs request,
+ out JsonObject cargoInventory,
+ out HashSet<(int x, int y)> pinned,
+ out (int x, int y) sourceSlotFilter,
+ out string sourceItemIdFilter)
+ {
+ cargoInventory = null!;
+ pinned = null!;
+ sourceSlotFilter = default;
+ sourceItemIdFilter = request.ItemId;
+
+ if (!TryGetSelectedShipCargoInventory(out cargoInventory, out _))
+ return false;
+
+ pinned = new HashSet<(int x, int y)>(_inventoryGrid.GetPinnedSlots());
+ sourceSlotFilter = (request.X, request.Y);
+
+ if (pinned.Contains(sourceSlotFilter))
+ {
+ MessageBox.Show(
+ UiStrings.Get("inventory.auto_stack_pinned_slot_blocked"),
+ UiStrings.Get("dialog.info"),
+ MessageBoxButtons.OK,
+ MessageBoxIcon.Information);
+ return false;
+ }
+
+ return true;
+ }
+
/// Applies BaseStatLimits min/max to a NumericUpDown control.
private static void ApplyStatLimits(NumericUpDown nud, string entityType, string statId, StatCategory category)
{
diff --git a/docs/user/README.md b/docs/user/README.md
index 333b142b..298023af 100644
--- a/docs/user/README.md
+++ b/docs/user/README.md
@@ -253,6 +253,16 @@ The **Exosuit** tab shows your personal inventory in a visual grid layout.
- Cargo and Technology inventories allow you to export and import an inventory layout via the Import and Export buttons.
+### Sort and Auto-Stack
+
+- Exosuit Cargo has a Sort control at the top of the grid.
+- The Auto-Stack button moves matching items from Exosuit Cargo to Chests, Starship Cargo, or Freighter Cargo.
+- The Exosuit Cargo context menu can also auto-stack only the slot you right-clicked.
+- You can pin a slot from the context menu. Pinned slots are ignored by auto-stack.
+- If you use the single-slot auto-stack action on a pinned slot, the action is blocked.
+- If the destination stack becomes full, the extra items go to another free slot in the same destination when possible.
+- If there is no valid free slot, the remaining items stay in Exosuit Cargo.
+
---
## Multi-tools
@@ -329,6 +339,16 @@ Export / Import via the Export and Import buttons.
Set the selected ship to the primary starship with the Make Primary button.
+### Cargo Sort and Auto-Stack
+
+- Starship Cargo has a Sort control at the top of the grid.
+- The Auto-Stack button in Starship Cargo moves matching items to Chests or Freighter Cargo.
+- The Starship Cargo context menu can also auto-stack only the slot you right-clicked.
+- You can pin a slot from the context menu. Pinned slots are ignored by auto-stack.
+- If you use the single-slot auto-stack action on a pinned slot, the action is blocked.
+- If the destination stack becomes full, the extra items go to another free slot in the same destination when possible.
+- If there is no valid free slot, the remaining items stay in Starship Cargo.
+
### Changing Ship Appearance
Changing the **Seed** value will change how your ship looks and it's base stats. Each seed generates a unique combination of parts and colours. You can find seeds shared by the community online to get specific ship appearances.
@@ -374,6 +394,8 @@ Edit your freighter's stats, inventory, technology, and view the functional room
Export / Import via the Export and Import buttons.
+- Freighter Cargo has a Sort control at the top of the grid.
+
### Frigates
Manage your fleet of up to 30 frigates by selecting them from the list.
@@ -509,6 +531,8 @@ You can move the base computer to another component's location via the Move
You can edit the contents of your numbered storage chests / containers (0β9) via tabs. Each container has its own inventory grid with export, import and usual editing capabilities.
+Chest inventories also have a Sort control at the top of the grid.
+
You can edit the names of your storage chests via the fields at the top of the tab.
| Chests |