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);
}
}
}
}