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 |