using System; using System.Collections.Generic; using System.IO; /// /// Maintains a per-NPC harvest inventory for trader-type entities (e.g. EntityAliveSDXV4). /// /// Problem: EntityAliveSDXV4 extends EntityTrader. Its lootContainer is a TileEntityTrader, /// whose write/read path serialises both SCoreLootContainer.items[] AND TraderData in /// one stream. SCoreLootContainer.AddItem() internally calls SetModified(), which /// triggers a TileEntityTrader network packet. Any mismatch between the items[] store and /// TraderData.PrimaryInventory causes EndOfStreamException on the receiving client. /// /// Solution: Store harvested crops in a plain SCoreLootContainer that is completely /// separate from the trader entity's lootContainer. This container is never attached to a /// chunk or to the world tile-entity system, so SetModified() is never called on it during /// harvest. The player accesses it via the standard looting window (OpenInventory dialog /// command), which reads directly from the in-memory items[] array. /// public static class HarvestManager { private const int ContainerWidth = 8; private const int ContainerHeight = 8; // Keyed by entity ID. Entries persist for the server session; removed when the NPC is // picked up (via EntitySyncUtils.Collect) or killed. private static readonly Dictionary _containers = new Dictionary(); // ------------------------------------------------------------------------- // Client-side pending state // ------------------------------------------------------------------------- // When a dedicated-server client opens a trader NPC's harvest inventory, // NetPackageHarvestInventoryData creates a temporary local SCoreLootContainer // and stores it here. XUiC_LootWindowGroup_OnClose reads these fields on close, // serialises whatever items remain, and sends them back to the server via // NetPackageHarvestInventoryUpdate so the server-side container stays accurate. // Both fields are -1 / null when no harvest window is open. public static int ClientPendingEntityId = -1; public static SCoreLootContainer ClientPendingContainer = null; /// Returns (or lazily creates) the harvest container for the given entity. public static SCoreLootContainer GetOrCreate(int entityId) { if (!_containers.TryGetValue(entityId, out var container)) { // XUiC_LootWindowGroup.OnOpen() calls TileEntity.get_blockValue(), which dereferences // this.chunk. A null chunk causes NullReferenceException before OnOpen can finish. // We resolve this two ways: // 1. Pass the entity's current chunk so blockValue returns a valid (if irrelevant) block. // 2. Set entityId so OnOpen takes the entity loot-stage path, which never casts // blockValue.Block to BlockCompositeTileEntity (only the block-based path does that cast). Chunk chunk = null; var world = GameManager.Instance?.World; if (world != null) { var entity = world.GetEntity(entityId); if (entity != null) chunk = world.GetChunkSync( World.toChunkXZ((int)entity.position.x), World.toChunkXZ((int)entity.position.z)) as Chunk; } container = new SCoreLootContainer(chunk); container.EntityId = entityId; container.lootListName = "traderNPC"; container.SetContainerSize(new Vector2i(ContainerWidth, ContainerHeight), true); // bTouched = false tells the vanilla loot system the chest has never been opened // and should have loot generated from lootListName — which would replace our items. // bPlayerStorage = true suppresses the loot-respawn tick. container.bTouched = true; container.bPlayerStorage = true; _containers[entityId] = container; } // Keep the container positioned on the NPC. XUiC_LootWindow auto-closes the loot // window when the player is farther than (cCollectItemDistance + 30) from // te.ToWorldCenterPos(). Created with only a chunk and localChunkPos=(0,0,0), the // container reported the chunk's bedrock corner (Y=0) ~60m below the player, so the // window slammed shut the instant it opened. Refresh every call since the NPC moves. PositionAtEntity(container, entityId); return container; } /// /// Point the container's tile-entity position at the entity's current block position so /// the loot window's distance auto-close measures against the NPC, not the chunk origin. /// Public so callers that build their own SCoreLootContainer (e.g. /// NetPackageHarvestInventoryData on a dedicated-server client) can apply the same fix. /// public static void PositionAtEntity(SCoreLootContainer container, int entityId) { var world = GameManager.Instance?.World; var entity = world?.GetEntity(entityId); if (entity == null) return; int bx = (int)entity.position.x, by = (int)entity.position.y, bz = (int)entity.position.z; if (world.GetChunkSync(World.toChunkXZ(bx), World.toChunkXZ(bz)) is Chunk chunk) container.chunk = chunk; container.localChunkPos = new Vector3i(bx - World.toChunkXZ(bx) * 16, by, bz - World.toChunkXZ(bz) * 16); } /// /// Adds an item to the entity's harvest container. /// Writes directly to items[] without calling SetModified, so no network packet is sent. /// Stacks with existing entries where possible. /// /// True if the item fit; false if the container was full (caller should drop). public static bool AddItem(int entityId, ItemStack itemStack) { var container = GetOrCreate(entityId); var items = container.items; // Pass 1 — try to stack with an existing slot for (int i = 0; i < items.Length; i++) { if (items[i].IsEmpty() || items[i].itemValue.type != itemStack.itemValue.type) continue; var cls = ItemClass.GetForId(itemStack.itemValue.type); if (cls == null || !cls.CanStack()) continue; int space = cls.Stacknumber.Value - items[i].count; if (space <= 0) continue; int toAdd = Math.Min(itemStack.count, space); items[i].count += toAdd; itemStack = new ItemStack(itemStack.itemValue, itemStack.count - toAdd); if (itemStack.count <= 0) return true; } // Pass 2 — find an empty slot for (int i = 0; i < items.Length; i++) { if (!items[i].IsEmpty()) continue; items[i] = itemStack.Clone(); return true; } return false; // container full } /// /// Removes the harvest container for an entity. /// Call this when the NPC is picked up (EntitySyncUtils.Collect) or dies. /// public static void Remove(int entityId) => _containers.Remove(entityId); /// Returns true if a non-empty harvest container exists for the entity. public static bool Has(int entityId) => _containers.ContainsKey(entityId); // ------------------------------------------------------------------------- // Persistence // ------------------------------------------------------------------------- private const string SaveFile = "HarvestManager.bin"; private const int SaveVersion = 1; /// /// Persists all harvest containers to disk in the current save game directory. /// Safe to call only on the server; silently no-ops on clients. /// public static void Save() { if (!SingletonMonoBehaviour.Instance.IsServer) return; if (!Directory.Exists(GameIO.GetSaveGameDir())) return; var path = $"{GameIO.GetSaveGameDir()}/{SaveFile}"; try { if (File.Exists(path)) File.Copy(path, $"{path}.bak", true); using var fs = File.Open(path, FileMode.Create, FileAccess.Write, FileShare.None); using var bw = new BinaryWriter(fs); bw.Write(SaveVersion); bw.Write(_containers.Count); foreach (var kvp in _containers) { bw.Write(kvp.Key); bw.Write(EntitySyncUtils.SerializeItemStackArray(kvp.Value.items) ?? string.Empty); } Log.Out($"[HarvestManager] Saved {_containers.Count} harvest container(s)."); } catch (Exception e) { Log.Error($"[HarvestManager] Save failed: {e}"); } } /// GameShutdown event handler — delegates to . public static void Save(ref ModEvents.SGameShutdownData data) => Save(); /// /// Loads harvest containers from disk. Registered as a GameStartDone handler so it /// runs after the world is ready. Only executes on the server. /// public static void Load(ref ModEvents.SGameStartDoneData data) { if (!SingletonMonoBehaviour.Instance.IsServer) return; var path = $"{GameIO.GetSaveGameDir()}/{SaveFile}"; if (!Directory.Exists(GameIO.GetSaveGameDir()) || !File.Exists(path)) { Log.Out("[HarvestManager] No save file found, starting fresh."); return; } TryLoad(path); } private static void TryLoad(string path) { try { using var fs = File.OpenRead(path); using var br = new BinaryReader(fs); int version = br.ReadInt32(); int count = br.ReadInt32(); for (int i = 0; i < count; i++) { int entityId = br.ReadInt32(); string serialized = br.ReadString(); var items = EntitySyncUtils.DeserializeItemStackArray(serialized); var container = GetOrCreate(entityId); for (int j = 0; j < items.Length && j < container.items.Length; j++) container.items[j] = items[j]; } Log.Out($"[HarvestManager] Loaded {_containers.Count} harvest container(s) from {path}."); } catch (Exception e) { Log.Error($"[HarvestManager] Load failed for '{path}': {e}"); var backup = $"{path}.bak"; if (File.Exists(backup) && path != backup) { Log.Warning("[HarvestManager] Attempting to load from backup..."); TryLoad(backup); } } } }