diff --git a/Properties/AssemblyInfo.cs b/Properties/AssemblyInfo.cs index 07aab73..3c6cb52 100644 --- a/Properties/AssemblyInfo.cs +++ b/Properties/AssemblyInfo.cs @@ -31,5 +31,5 @@ // You can specify all the values or you can default the Build and Revision Numbers // by using the '*' as shown below: // [assembly: AssemblyVersion("1.0.*")] -[assembly: AssemblyVersion("0.1.85")] -[assembly: AssemblyFileVersion("0.1.85")] +[assembly: AssemblyVersion("0.1.86")] +[assembly: AssemblyFileVersion("0.1.86")] diff --git a/Source/Core/Plugin.cs b/Source/Core/Plugin.cs index 8c7e398..b7e6312 100644 --- a/Source/Core/Plugin.cs +++ b/Source/Core/Plugin.cs @@ -29,7 +29,7 @@ namespace PraetorisClient public class PraetorisClientPlugin : BaseUnityPlugin { private const string ModName = "PraetorisClient"; - private const string ModVersion = "0.1.85"; + private const string ModVersion = "0.1.86"; private const string Author = "warpalicious"; private const string ModGUID = Author + "." + ModName; private const string EpicLootGuid = "randyknapp.mods.epicloot"; diff --git a/Source/ServerChest/ServerChest.cs b/Source/ServerChest/ServerChest.cs index 98e1f0f..a9e82ec 100644 --- a/Source/ServerChest/ServerChest.cs +++ b/Source/ServerChest/ServerChest.cs @@ -13,18 +13,16 @@ internal sealed class ServerChest : MonoBehaviour internal const string OwnerNameLookupKey = "ServerChestOwnerNameKey"; internal const string OwnerPlatformIdKey = "ServerChestOwnerPlatformId"; internal const string OwnerLookupKey = "ServerChestOwnerKey"; - internal const int MaxColumns = 8; - internal const int MaxRows = 8; - internal const int MaxSlots = MaxColumns * MaxRows; + internal const int Columns = 8; private static readonly Dictionary InventoryOwners = new(); private static readonly FieldInfoWrapper InventoryWidth = new(typeof(Inventory), "m_width"); private static readonly FieldInfoWrapper InventoryHeight = new(typeof(Inventory), "m_height"); - private static bool _suppressInventoryChanged; private Container? _container; private Inventory? _inventory; private ZNetView? _nview; + internal byte[]? LoadedItemData; private void Awake() { @@ -43,7 +41,6 @@ private void OnDestroy() { if (_inventory != null) { - _inventory.m_onChanged -= OnInventoryChanged; InventoryOwners.Remove(_inventory); } } @@ -63,28 +60,13 @@ private void RefreshInventoryRegistration() if (_inventory != null) { - _inventory.m_onChanged -= OnInventoryChanged; InventoryOwners.Remove(_inventory); } _inventory = inventory; InventoryOwners[inventory] = this; - ApplyMaxInventoryShape(inventory); + ResizeToFit(inventory); ServerChestLog.Debug("registered live inventory zdo=" + GetZdoId() + " items=" + inventory.NrOfItems().ToString(CultureInfo.InvariantCulture)); - inventory.m_onChanged += OnInventoryChanged; - } - - private void OnInventoryChanged() - { - if (_suppressInventoryChanged || _inventory == null || _nview == null || !_nview.IsValid() || !_nview.IsOwner()) - { - return; - } - - CompactInventory(_inventory); - ApplyMaxInventoryShape(_inventory); - SaveInventoryToZdo(_nview.GetZDO(), _inventory); - ServerChestLog.Debug("live inventory changed zdo=" + _nview.GetZDO().m_uid + " stacks=" + _inventory.NrOfItems().ToString(CultureInfo.InvariantCulture) + " items=" + _inventory.NrOfItemsIncludingStacks().ToString(CultureInfo.InvariantCulture)); } private void OnDestroyed() @@ -198,9 +180,11 @@ internal static string OwnerLookup(ZDO zdo) return zdo.GetString(OwnerLookupKey); } - internal static void ApplyMaxInventoryShape(Inventory inventory) + internal static void ResizeToFit(Inventory inventory, int additionalSlots = 0) { - ApplyInventoryShape(inventory, MaxColumns, MaxRows); + int slots = checked(inventory.NrOfItems() + additionalSlots); + int rows = Math.Max(1, slots / Columns + (slots % Columns == 0 ? 0 : 1)); + ApplyInventoryShape(inventory, Columns, rows); } internal static void ApplyInventoryShape(Inventory inventory, int columns, int rows) @@ -211,7 +195,7 @@ internal static void ApplyInventoryShape(Inventory inventory, int columns, int r internal static void CompactInventory(Inventory inventory) { - CompactInventory(inventory, MaxColumns); + CompactInventory(inventory, Columns); } internal static void CompactInventory(Inventory inventory, int columns) @@ -225,22 +209,23 @@ internal static void CompactInventory(Inventory inventory, int columns) internal static Inventory LoadInventoryFromZdo(ZDO zdo) { - Inventory inventory = new("ServerChest", null, MaxColumns, MaxRows); + Inventory inventory = new("ServerChest", null, Columns, 1); byte[]? data = zdo.GetByteArray(ZDOVars.s_items); if (data != null && data.Length > 0) { try { - inventory.Load(new ZPackage(data)); + ServerChestStorage.Load(inventory, new ZPackage(data)); } catch (Exception ex) { PraetorisClientPlugin.Log.LogWarning("Failed to load ServerChest inventory from ZDO " + zdo.m_uid + ": " + ex.Message); + throw; } } - ApplyMaxInventoryShape(inventory); CompactInventory(inventory); + ResizeToFit(inventory); ServerChestLog.Debug("loaded inventory zdo=" + zdo.m_uid + " stacks=" + inventory.NrOfItems().ToString(CultureInfo.InvariantCulture) + " items=" + inventory.NrOfItemsIncludingStacks().ToString(CultureInfo.InvariantCulture) + " dataLength=" + (data?.Length ?? 0).ToString(CultureInfo.InvariantCulture)); return inventory; } @@ -249,7 +234,8 @@ internal static void SaveInventoryToZdo(ZDO zdo, Inventory inventory) { CompactInventory(inventory); ZPackage package = new(); - inventory.Save(package); + ResizeToFit(inventory); + ServerChestStorage.Save(inventory, package); byte[] data = package.GetArray(); zdo.Set(ZDOVars.s_items, data); ServerChestLog.Debug("saved inventory zdo=" + zdo.m_uid + " stacks=" + inventory.NrOfItems().ToString(CultureInfo.InvariantCulture) + " items=" + inventory.NrOfItemsIncludingStacks().ToString(CultureInfo.InvariantCulture) + " dataLength=" + data.Length.ToString(CultureInfo.InvariantCulture)); @@ -272,20 +258,6 @@ internal static List FindAllZdos() return result; } - internal static void WithSuppressedInventoryChanged(Action action) - { - bool previous = _suppressInventoryChanged; - _suppressInventoryChanged = true; - try - { - action(); - } - finally - { - _suppressInventoryChanged = previous; - } - } - internal static void ShowMessage(string message) { if (Player.m_localPlayer != null) diff --git a/Source/ServerChest/ServerChestPatches.cs b/Source/ServerChest/ServerChestPatches.cs index e6089ca..06a772c 100644 --- a/Source/ServerChest/ServerChestPatches.cs +++ b/Source/ServerChest/ServerChestPatches.cs @@ -44,7 +44,7 @@ private static bool Prefix(InventoryGrid __instance, Inventory inventory) return true; } - ServerChest.ApplyMaxInventoryShape(inventory); + ServerChest.ResizeToFit(inventory); return true; } } @@ -53,6 +53,7 @@ private static bool Prefix(InventoryGrid __instance, Inventory inventory) internal static class ServerChestInventoryGuiUpdateContainerPatch { private static readonly FieldInfo? CurrentContainerField = AccessTools.Field(typeof(InventoryGui), "m_currentContainer"); + private static readonly FieldInfo GridElementsField = AccessTools.Field(typeof(InventoryGrid), "m_elements"); private static bool GridWasCompacted; private static void Postfix(InventoryGui __instance) @@ -86,7 +87,7 @@ private static void Postfix(InventoryGui __instance) if (containerGrid.m_gridRoot != null) { containerGrid.m_gridRoot.gameObject.SetActive(visibleSlots > 0); - float visibleRows = visibleSlots <= 0 ? 0f : (float)Math.Ceiling(visibleSlots / (double)ServerChest.MaxColumns); + float visibleRows = visibleSlots <= 0 ? 0f : (float)Math.Ceiling(visibleSlots / (double)ServerChest.Columns); containerGrid.m_gridRoot.SetSizeWithCurrentAnchors(RectTransform.Axis.Vertical, visibleRows * containerGrid.m_elementSpace); } @@ -106,7 +107,7 @@ private static void RestoreGrid(InventoryGrid inventoryGrid) private static void SetGridElementsActive(InventoryGrid inventoryGrid, int visibleSlots) { - List elements = inventoryGrid.m_elements; + List elements = (List)GridElementsField.GetValue(inventoryGrid); for (int index = 0; index < elements.Count; index++) { InventoryElement element = elements[index]; diff --git a/Source/ServerChest/ServerChestPiece.cs b/Source/ServerChest/ServerChestPiece.cs index 624befb..cd6749c 100644 --- a/Source/ServerChest/ServerChestPiece.cs +++ b/Source/ServerChest/ServerChestPiece.cs @@ -67,8 +67,8 @@ private static void ConfigurePrefab(GameObject prefab) if (container != null) { container.m_name = "Server Chest"; - container.m_width = ServerChest.MaxColumns; - container.m_height = ServerChest.MaxRows; + container.m_width = ServerChest.Columns; + container.m_height = 1; container.m_defaultItems = new DropTable(); container.m_autoDestroyEmpty = false; container.m_discoverStat = PlayerStatType.None; diff --git a/Source/ServerChest/ServerChestService.cs b/Source/ServerChest/ServerChestService.cs index f3eb318..f1e230e 100644 --- a/Source/ServerChest/ServerChestService.cs +++ b/Source/ServerChest/ServerChestService.cs @@ -105,44 +105,27 @@ internal static CommandResult SendItems(string characterName, IReadOnlyList + foreach (SendItem item in items) { - ServerChest.ApplyMaxInventoryShape(inventory); - foreach (SendItem item in items) + if (!TryAddItemAmount(inventory, item, out string error)) { - if (!TryAddItemAmount(inventory, item, out string error)) - { - addFailed = true; - addFailure = error; - return; - } + return CommandResult.Fail("ServerChest delivery failed while adding items: " + error + " No delivery was saved."); } - - if (!addFailed) - { - ServerChest.SaveInventoryToZdo(zdo, inventory); - } - }); - - if (addFailed) - { - ServerChestLog.Debug("send failed owner=" + characterName + " zdo=" + zdo.m_uid + " error=" + addFailure); - return CommandResult.Fail("ServerChest delivery failed while adding items: " + addFailure + " No delivery was saved."); } + ServerChest.SaveInventoryToZdo(zdo, inventory); - int totalAmount = items.Sum(item => item.Amount); + long totalAmount = items.Sum(item => (long)item.Amount); ServerChestLog.Debug("send complete owner=" + ServerChest.OwnerName(zdo) + " zdo=" + zdo.m_uid + " delivered=" + totalAmount.ToString(CultureInfo.InvariantCulture) + " finalStacks=" + inventory.NrOfItems().ToString(CultureInfo.InvariantCulture) + " finalItems=" + inventory.NrOfItemsIncludingStacks().ToString(CultureInfo.InvariantCulture)); return CommandResult.Ok("Delivered " + totalAmount.ToString(CultureInfo.InvariantCulture) + " item(s) to ServerChest for " + ServerChest.OwnerName(zdo) + "."); } @@ -155,11 +138,19 @@ internal static CommandResult Status(string characterName) return lookup; } - Inventory inventory = ServerChest.LoadInventoryFromZdo(zdo); + Inventory inventory; + try + { + inventory = ServerChest.LoadInventoryFromZdo(zdo); + } + catch (Exception) + { + return CommandResult.Fail("ServerChest contents could not be loaded. Status is unavailable. Check the server log for details."); + } int stackCount = inventory.NrOfItems(); int itemCount = inventory.NrOfItemsIncludingStacks(); - int width = stackCount <= 0 ? 0 : ServerChest.MaxColumns; - int height = stackCount <= 0 ? 0 : Math.Max(1, (int)Math.Ceiling(stackCount / (double)ServerChest.MaxColumns)); + int width = stackCount <= 0 ? 0 : ServerChest.Columns; + int height = stackCount <= 0 ? 0 : inventory.GetHeight(); Vector3 position = zdo.GetPosition(); string message = "ServerChest owner=" + ServerChest.OwnerName(zdo) + @@ -269,53 +260,6 @@ internal static CommandResult ValidateItems(IReadOnlyList items) return CommandResult.Ok(""); } - private static int CountRequiredNewSlots(Inventory inventory, IReadOnlyList items) - { - int required = 0; - Dictionary freeStackSpace = new(); - foreach (ItemDrop.ItemData existing in inventory.GetAllItems()) - { - string key = StackKey(existing.m_shared.m_name, existing.m_quality, existing.m_worldLevel); - int free = Math.Max(0, existing.m_shared.m_maxStackSize - existing.m_stack); - if (!freeStackSpace.ContainsKey(key)) - { - freeStackSpace[key] = 0; - } - - freeStackSpace[key] += free; - } - - foreach (SendItem item in items) - { - GameObject prefab = ObjectDB.instance.GetItemPrefab(item.PrefabName); - ItemDrop itemDrop = prefab.GetComponent(); - int maxStack = Math.Max(1, itemDrop.m_itemData.m_shared.m_maxStackSize); - int worldLevel = (int)(byte)Game.m_worldLevel; - string key = StackKey(itemDrop.m_itemData.m_shared.m_name, item.Quality, worldLevel); - int remaining = item.Amount; - if (freeStackSpace.TryGetValue(key, out int free) && free > 0) - { - int used = Math.Min(remaining, free); - remaining -= used; - freeStackSpace[key] = free - used; - } - - if (remaining > 0) - { - int newSlots = (int)Math.Ceiling(remaining / (double)maxStack); - required += newSlots; - freeStackSpace[key] = newSlots * maxStack - remaining; - } - } - - return required; - } - - private static string StackKey(string sharedName, int quality, int worldLevel) - { - return sharedName + "|" + quality.ToString(CultureInfo.InvariantCulture) + "|" + worldLevel.ToString(CultureInfo.InvariantCulture); - } - private static bool TryAddItemAmount(Inventory inventory, SendItem item, out string error) { error = ""; @@ -331,6 +275,7 @@ private static bool TryAddItemAmount(Inventory inventory, SendItem item, out str while (remaining > 0) { int stackAmount = Math.Min(remaining, maxStack); + ServerChest.ResizeToFit(inventory, 1); int chunkBeforeAmount = CountMatchingAmount(inventory, sharedName, item.Quality, worldLevel); ItemDrop.ItemData added = inventory.AddItem(item.PrefabName, stackAmount, item.Quality, 0, 0L, "", cheated: false); int chunkAfterAmount = CountMatchingAmount(inventory, sharedName, item.Quality, worldLevel); diff --git a/Source/ServerChest/ServerChestStorage.cs b/Source/ServerChest/ServerChestStorage.cs new file mode 100644 index 0000000..46ff97a --- /dev/null +++ b/Source/ServerChest/ServerChestStorage.cs @@ -0,0 +1,145 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using HarmonyLib; + +namespace PraetorisClient.ServerChestFeature +{ + // Vanilla saves use byte row coordinates and a ushort stack count. Keep each + // embedded vanilla inventory within those bounds, then restore global rows. + internal static class ServerChestStorage + { + private const int FormatMarker = -1396918321; + private const int FormatVersion = 1; + private const int ChunkSlots = ServerChest.Columns * 256; + private static readonly System.Reflection.MethodInfo InventoryChanged = AccessTools.Method(typeof(Inventory), "Changed"); + + internal static void Save(Inventory inventory, ZPackage package) + { + ServerChest.CompactInventory(inventory); + List items = inventory.GetAllItemsInGridOrder(); + if (items.Count <= ChunkSlots) + { + inventory.Save(package); + return; + } + + package.Write(FormatMarker); + package.Write(FormatVersion); + package.Write(items.Count); + for (int start = 0; start < items.Count; start += ChunkSlots) + { + Inventory chunk = new("ServerChest", null, ServerChest.Columns, 256); + int count = Math.Min(ChunkSlots, items.Count - start); + for (int index = 0; index < count; index++) + { + ItemDrop.ItemData item = items[start + index].Clone(); + item.m_gridPos = new Vector2i(index % ServerChest.Columns, index / ServerChest.Columns); + chunk.GetAllItems().Add(item); + } + + ZPackage data = new(); + chunk.Save(data); + package.Write(data); + } + } + + internal static void Load(Inventory inventory, ZPackage package) + { + // Decode into a separate inventory: a failed read must not replace + // existing contents or trigger a save of partially loaded items. + Inventory loaded = new("ServerChest", null, ServerChest.Columns, 256); + List items; + int position = package.GetPos(); + if (package.ReadInt() != FormatMarker) + { + package.SetPos(position); + loaded.Load(package); + items = loaded.GetAllItemsInGridOrder(); + } + else + { + if (package.ReadInt() != FormatVersion) + throw new InvalidDataException("Unsupported ServerChest storage version."); + int count = package.ReadInt(); + if (count <= ChunkSlots) + throw new InvalidDataException("Invalid ServerChest stack count."); + for (int start = 0; start < count; start += ChunkSlots) + { + ZPackage data = package.ReadPackage(); + Inventory chunk = new("ServerChest", null, ServerChest.Columns, 256); + chunk.Load(data); + if (data.GetPos() != data.Size() || chunk.NrOfItems() != Math.Min(ChunkSlots, count - start)) + throw new InvalidDataException("Incomplete ServerChest storage chunk."); + // Serialization order is the order of slots across chunks. + loaded.GetAllItems().AddRange(chunk.GetAllItemsInGridOrder()); + } + items = loaded.GetAllItems(); + } + + for (int index = 0; index < items.Count; index++) + items[index].m_gridPos = new Vector2i(index % ServerChest.Columns, index / ServerChest.Columns); + inventory.GetAllItems().Clear(); + inventory.GetAllItems().AddRange(items); + ServerChest.ResizeToFit(inventory); + InventoryChanged.Invoke(inventory, new object[] { false, false }); + } + } + + [HarmonyPatch(typeof(Container), "Save")] + internal static class ServerChestContainerSavePatch + { + private static bool Prefix(Container __instance, ZNetView ___m_nview, ref uint ___m_lastRevision) + { + ServerChest chest = __instance.GetComponent(); + if (chest == null) return true; + ZDO zdo = ___m_nview.GetZDO(); + ServerChest.SaveInventoryToZdo(zdo, __instance.GetInventory()); + chest.LoadedItemData = zdo.GetByteArray(ZDOVars.s_items); + ___m_lastRevision = zdo.DataRevision; + return false; + } + } + + [HarmonyPatch(typeof(Container), "Load")] + internal static class ServerChestContainerLoadPatch + { + private static bool Prefix(Container __instance, ref bool __result, ZNetView ___m_nview, + ref uint ___m_lastRevision, ref bool ___m_loading) + { + ServerChest chest = __instance.GetComponent(); + if (chest == null) return true; + ZDO zdo = ___m_nview.GetZDO(); + // Deliveries can arrive after the open response. Unlike ordinary + // chests, this inventory must accept those updates while open. + // Local withdrawals already save and update the byte cache below. + if (zdo.DataRevision == ___m_lastRevision) + return false; + byte[] data = zdo.GetByteArray(ZDOVars.s_items); + // Opening and closing changes the ZDO revision too. Reuse the live + // inventory when the item bytes have not changed. + if (ReferenceEquals(data, chest.LoadedItemData) || + (data != null && chest.LoadedItemData != null && data.SequenceEqual(chest.LoadedItemData))) + { + ___m_lastRevision = zdo.DataRevision; + __result = true; + return false; + } + ___m_loading = true; + try + { + if (data != null && data.Length > 0) + ServerChestStorage.Load(__instance.GetInventory(), new ZPackage(data)); + chest.LoadedItemData = data; + ___m_lastRevision = zdo.DataRevision; + __result = true; + } + finally + { + ___m_loading = false; + } + return false; + } + } +} diff --git a/manifest.json b/manifest.json index b024e2d..f976c72 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "name": "PraetorisClient", "description": "Client-side Praetoris tools for Discord linking, creative server bridges, RPC tracing, socket metrics, and RPC probes.", - "version_number": "0.1.85", + "version_number": "0.1.86", "website_url": "https://discord.gg/3aaru2VyHJ", "dependencies": [ "denikson-BepInExPack_Valheim-5.4.2333",