using System; using FishNet.Connection; using FishNet.Object; using FishNet.Object.Synchronizing; using UnityEngine; using Ashwild.Interaction; using Ashwild.Inventory; namespace Ashwild.Storage { /// /// A networked storage chest and the single IInteractable that opens it. Interacting opens the /// shared (the player's inventory on the left, this chest on the right); all /// item moves are requested through server RPCs. /// /// Multiplayer: the chest is **server-authoritative** so two players browsing the same chest can /// never clobber or duplicate items. The server owns the rich model ([]) /// and replicates a compact per-slot view (: item id + quantity + uses) /// through a SyncList that every client renders from. The player inventory itself stays /// client-authoritative: depositing removes the item locally then asks the server to store it /// (refunded if it no longer fits), and withdrawing has the server grant the item back through /// — mirroring the CookingStation pattern. /// [DisallowMultipleComponent] [RequireComponent(typeof(NetworkObject))] public class Chest : NetworkBehaviour, IInteractable { #region Types /// /// Replicated per-slot view: the stored item's network id (0 = empty), how many, and the /// remaining uses of a uses-tracked instance (-1 for items without a uses bar). Clients render /// the slot from this; the authoritative model stays server-side. /// public struct ChestSlotView { public ushort rawId; public int quantity; public int uses; } #endregion #region Serialized Fields [Header("Data")] [Tooltip("Authoring data for this chest: slot count, title and grid width.")] [SerializeField] private ChestData data; #endregion #region Networked State /// /// One view per storage slot, seeded server-side and replicated to every client. The UI reads /// this on both the server and clients so a host and a pure client render identically. /// private readonly SyncList slotViews = new SyncList(); #endregion #region Server State /// /// Server-only authoritative contents, parallel to . Null on clients, /// which never read it — every mutation happens inside a ServerRpc body. /// private InventorySlot[] slots; #endregion #region Events /// /// Fired when a slot's replicated view changes so the open can refresh /// just that cell. An index of -1 means the whole collection changed (a full refresh). /// public event Action SlotViewChanged; #endregion #region Public API /// /// Number of storage slots this chest exposes, taken from its data (falls back to the /// replicated view count if the data is missing). /// public int SlotCount => data != null ? data.SlotCount : slotViews.Count; /// /// Title shown at the top of the chest window. /// public string DisplayName => data != null ? data.DisplayName : "Coffre"; #endregion #region Unity Lifecycle /// /// Warns early when the chest has no data, so a misconfigured prefab is obvious in the console. /// private void Awake() { if (data == null) Debug.LogError($"[Chest] '{name}' has no ChestData assigned — the chest will have no slots.", this); } #endregion #region Network Lifecycle /// /// Subscribes to the replicated state so the open window follows it on every machine. /// public override void OnStartNetwork() { base.OnStartNetwork(); slotViews.OnChange += OnSlotViewChanged; } /// /// Server-side: builds the authoritative slot model and seeds one empty view per slot so /// clients receive a correctly sized collection. /// public override void OnStartServer() { base.OnStartServer(); int count = data != null ? data.SlotCount : 0; slots = new InventorySlot[count]; for (int i = 0; i < count; i++) slots[i] = new InventorySlot(); slotViews.Clear(); for (int i = 0; i < count; i++) slotViews.Add(new ChestSlotView { rawId = 0, quantity = 0, uses = -1 }); } /// /// Unsubscribes — mirrors OnStartNetwork exactly. /// public override void OnStopNetwork() { base.OnStopNetwork(); slotViews.OnChange -= OnSlotViewChanged; } #endregion #region IInteractable /// /// Prompt shown while aiming at the chest. /// public string InteractionPrompt => $"Ouvrir {DisplayName}"; /// /// Opens the shared chest window bound to this chest. Runs on the interacting client only. /// public void Interact() { if (ChestUI.Instance == null) { Debug.LogError("[Chest] No ChestUI found in the scene — cannot open the chest.", this); return; } ChestUI.Instance.Open(this); } #endregion #region View Access /// /// Resolves a slot's stored item from the replicated view. Returns false for an empty slot or /// an unknown id. Safe on server and clients (both read the replicated view). /// public bool TryGetSlot(int index, out ItemData item, out int quantity, out int uses) { item = null; quantity = 0; uses = -1; if (index < 0 || index >= slotViews.Count) return false; ChestSlotView v = slotViews[index]; if (v.rawId == 0) return false; item = ItemDatabase.Instance != null ? ItemDatabase.Instance.GetItem(v.rawId) : null; if (item == null) return false; quantity = v.quantity; uses = v.uses; return true; } #endregion #region Client Requests /// /// Deposits the whole stack in the given inventory slot into a specific chest slot. When the /// target chest slot holds a different item this becomes a swap, so it first checks the /// displaced stack still fits in the inventory (server refunds anything the target can't hold). /// Pass a negative chest slot to let the server auto-place into the first fitting slot. /// public void RequestDepositToSlot(int inventoryIndex, int chestSlot) { PlayerInventory inv = PlayerInventory.Instance; if (inv == null) return; InventorySlot slot = inv.GetSlot(inventoryIndex); if (slot == null || slot.IsEmpty) return; ItemData item = slot.ItemData; int quantity = slot.Quantity; int uses = item.HasUses ? slot.CurrentUses : -1; ushort id = ItemDatabase.Instance != null ? ItemDatabase.Instance.GetId(item) : (ushort)0; if (id == 0) { Debug.LogError($"[Chest] '{item.ItemName}' is not in the ItemDatabase — cannot store. Run Rebuild Item Database.", this); return; } if (chestSlot >= 0 && chestSlot < slotViews.Count) { ChestSlotView target = slotViews[chestSlot]; if (target.rawId != 0) { ItemData targetItem = ItemDatabase.Instance != null ? ItemDatabase.Instance.GetItem(target.rawId) : null; bool sameStackable = targetItem == item && item.IsStackable && !item.HasUses; if (!sameStackable && targetItem != null && !inv.CanFit(targetItem, target.quantity)) { Debug.LogWarning($"[Chest] Inventaire plein — impossible d'échanger avec '{targetItem.ItemName}'.", this); return; } } } inv.RemoveItem(inventoryIndex, quantity); RequestDepositServerRpc(chestSlot, id, quantity, uses); } /// /// Deposits every non-empty inventory slot into the chest, auto-placing each. Safe: the server /// refunds anything that does not fit back to the same player. /// public void RequestDepositAll() { PlayerInventory inv = PlayerInventory.Instance; if (inv == null) return; for (int i = 0; i < inv.InventorySize; i++) { InventorySlot slot = inv.GetSlot(i); if (slot == null || slot.IsEmpty) continue; RequestDepositToSlot(i, -1); } } /// /// Withdraws a chest slot's whole stack into the inventory, after a local CanFit pre-check so a /// full inventory leaves the items in the chest instead of losing them. /// public void RequestWithdraw(int chestSlot) { PlayerInventory inv = PlayerInventory.Instance; if (inv == null) return; if (!TryGetSlot(chestSlot, out ItemData item, out int quantity, out _)) return; if (!inv.CanFit(item, quantity)) { Debug.LogWarning($"[Chest] Inventaire plein — impossible de retirer '{item.ItemName}'.", this); return; } RequestWithdrawServerRpc(chestSlot, quantity); } /// /// Withdraws every non-empty chest slot into the inventory. Each slot is CanFit-checked as it /// goes. Because the grants come back asynchronously, the checks do not yet account for items /// still in flight, so a chest larger than the free inventory space may leave some items behind. /// public void RequestWithdrawAll() { for (int i = 0; i < slotViews.Count; i++) if (slotViews[i].rawId != 0) RequestWithdraw(i); } /// /// Moves/merges one chest slot into another (a drag within the chest grid). /// public void RequestMoveWithin(int fromSlot, int toSlot) { RequestMoveWithinServerRpc(fromSlot, toSlot); } #endregion #region Server RPCs /// /// Server-side deposit. Places the incoming stack in the requested slot — filling an empty /// slot, merging into a matching stack, or swapping with a different item (the displaced stack /// is granted back to the player) — and refunds any leftover. A negative slot auto-places. /// [ServerRpc(RequireOwnership = false)] private void RequestDepositServerRpc(int chestSlot, ushort id, int quantity, int uses, NetworkConnection conn = null) { ItemData item = ItemDatabase.Instance != null ? ItemDatabase.Instance.GetItem(id) : null; if (item == null || quantity <= 0) return; if (chestSlot < 0 || chestSlot >= slots.Length) { ServerDepositAuto(item, quantity, uses, conn); return; } InventorySlot target = slots[chestSlot]; if (target.IsEmpty) { int place = PlaceableCount(item, quantity); target.Set(item, place, uses); WriteView(chestSlot); GrantBack(conn, item, quantity - place, uses); } else if (!item.HasUses && target.ItemData == item && target.CanAccept(item)) { int leftover = target.AddQuantity(quantity); WriteView(chestSlot); GrantBack(conn, item, leftover, uses); } else { ItemData displaced = target.ItemData; int displacedQty = target.Quantity; int displacedUses = displaced != null && displaced.HasUses ? target.CurrentUses : -1; int place = PlaceableCount(item, quantity); target.Set(item, place, uses); WriteView(chestSlot); GrantBack(conn, item, quantity - place, uses); GrantBack(conn, displaced, displacedQty, displacedUses); } } /// /// Server-side withdraw. Removes the whole stack from the chest slot and grants it to the /// requesting player. /// [ServerRpc(RequireOwnership = false)] private void RequestWithdrawServerRpc(int chestSlot, int quantity, NetworkConnection conn = null) { if (chestSlot < 0 || chestSlot >= slots.Length) return; InventorySlot s = slots[chestSlot]; if (s.IsEmpty || quantity <= 0) return; ItemData item = s.ItemData; int take = Mathf.Min(quantity, s.Quantity); int uses = item.HasUses ? s.CurrentUses : -1; s.RemoveQuantity(take); WriteView(chestSlot); GrantBack(conn, item, take, uses); } /// /// Server-side move within the chest: merges into a matching stack, otherwise swaps the two /// slots (uses preserved). Never touches any inventory. /// [ServerRpc(RequireOwnership = false)] private void RequestMoveWithinServerRpc(int fromSlot, int toSlot, NetworkConnection conn = null) { if (fromSlot < 0 || fromSlot >= slots.Length) return; if (toSlot < 0 || toSlot >= slots.Length) return; if (fromSlot == toSlot) return; InventorySlot a = slots[fromSlot]; if (a.IsEmpty) return; InventorySlot b = slots[toSlot]; if (!b.IsEmpty && !a.ItemData.HasUses && b.ItemData == a.ItemData && b.CanAccept(a.ItemData)) { int leftover = b.AddQuantity(a.Quantity); if (leftover <= 0) a.Clear(); else a.Set(a.ItemData, leftover); } else { ItemData ai = a.ItemData; int aq = a.Quantity; int au = ai != null && ai.HasUses ? a.CurrentUses : -1; ItemData bi = b.ItemData; int bq = b.Quantity; int bu = bi != null && bi.HasUses ? b.CurrentUses : -1; if (bi == null) a.Clear(); else a.Set(bi, bq, bu); if (ai == null) b.Clear(); else b.Set(ai, aq, au); } WriteView(fromSlot); WriteView(toSlot); } #endregion #region Server Helpers /// /// Auto-places an incoming stack: merges into matching non-full slots first, then fills empty /// slots (one instance per slot for uses-tracked items), and refunds any leftover. /// private void ServerDepositAuto(ItemData item, int quantity, int uses, NetworkConnection conn) { int remaining = quantity; if (!item.HasUses) { for (int i = 0; i < slots.Length && remaining > 0; i++) { if (!slots[i].IsEmpty && slots[i].ItemData == item && slots[i].CanAccept(item)) { remaining = slots[i].AddQuantity(remaining); WriteView(i); } } } for (int i = 0; i < slots.Length && remaining > 0; i++) { if (!slots[i].IsEmpty) continue; if (item.HasUses) { slots[i].Set(item, 1, uses); remaining -= 1; } else { int place = item.IsStackable ? Mathf.Min(remaining, item.MaxStackSize) : 1; slots[i].Set(item, place); remaining -= place; } WriteView(i); } GrantBack(conn, item, remaining, uses); } /// /// How many units of an item can sit in a single fresh slot: one for uses-tracked or /// non-stackable items, otherwise up to the max stack size. /// private int PlaceableCount(ItemData item, int quantity) { int cap = item.HasUses ? 1 : (item.IsStackable ? item.MaxStackSize : 1); return Mathf.Min(quantity, cap); } /// /// Pushes the server model of a slot into its replicated view. /// private void WriteView(int index) { InventorySlot s = slots[index]; ChestSlotView v; if (s.IsEmpty) { v = new ChestSlotView { rawId = 0, quantity = 0, uses = -1 }; } else { ushort id = ItemDatabase.Instance != null ? ItemDatabase.Instance.GetId(s.ItemData) : (ushort)0; v = new ChestSlotView { rawId = id, quantity = s.Quantity, uses = s.ItemData.HasUses ? s.CurrentUses : -1 }; } slotViews[index] = v; } /// /// Grants an item back to the requesting player's (client-authoritative) inventory. No-op for /// a null item or a non-positive quantity. /// private void GrantBack(NetworkConnection conn, ItemData item, int quantity, int uses) { if (item == null || quantity <= 0) return; PlayerInventory inv = ResolveInventory(conn); if (inv != null) inv.GrantItemFromServer(item, quantity, uses); } /// /// Returns the PlayerInventory on the player object owned by the given connection. /// private PlayerInventory ResolveInventory(NetworkConnection conn) { NetworkObject playerObject = conn != null ? conn.FirstObject : null; return playerObject != null ? playerObject.GetComponent() : null; } #endregion #region Replication Handlers /// /// Forwards a replicated slot change to the open window: a single index for point edits, or -1 /// when the whole collection is (re)seeded on join. /// private void OnSlotViewChanged(SyncListOperation op, int index, ChestSlotView oldItem, ChestSlotView newItem, bool asServer) { switch (op) { case SyncListOperation.Add: case SyncListOperation.Insert: case SyncListOperation.Set: case SyncListOperation.RemoveAt: SlotViewChanged?.Invoke(index); break; case SyncListOperation.Clear: case SyncListOperation.Complete: SlotViewChanged?.Invoke(-1); break; } } #endregion } }