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 /// inventory window in chest mode (the player's inventory on the left, this chest's module 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 inventory window in chest mode, bound to this chest. Runs on the interacting client /// only. /// public void Interact() { if (InventoryUI.Instance == null) { Debug.LogError("[Chest] No InventoryUI found in the scene — cannot open the chest.", this); return; } InventoryUI.Instance.OpenChest(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 /// /// The one drag-and-drop operation for anything involving this chest: chest→inventory, /// inventory→chest and chest→chest, in either direction. It never decides *what* happens — the /// shared rules do — so dropping a stack on a chest cell behaves /// exactly like dropping it on an inventory cell (move, merge or swap). /// /// For a cross-container move the player's slot is read and cleared locally (the inventory is /// client-authoritative) and sent along as a payload; the server reconciles it against the chest /// slot and grants whatever comes back into that same slot, which is why an item never lands /// somewhere unexpected like the first free hotbar cell. /// public void RequestMove(SlotContainer fromContainer, int fromIndex, SlotContainer toContainer, int toIndex) { if (fromContainer == SlotContainer.Chest && toContainer == SlotContainer.Chest) { if (fromIndex != toIndex) MoveWithinServerRpc(fromIndex, toIndex); return; } PlayerInventory inv = PlayerInventory.Instance; if (inv == null) return; bool chestIsSource = fromContainer == SlotContainer.Chest; int chestIndex = chestIsSource ? fromIndex : toIndex; int inventoryIndex = chestIsSource ? toIndex : fromIndex; if (chestIndex < 0 || chestIndex >= slotViews.Count) return; if (inv.GetSlot(inventoryIndex) == null) return; SlotContent payload = inv.ReadSlot(inventoryIndex); bool chestSlotFilled = TryGetSlot(chestIndex, out _, out _, out _); // Nothing to move: the side the player dragged from is empty. if (chestIsSource ? !chestSlotFilled : payload.IsEmpty) return; ushort id = 0; if (!payload.IsEmpty) { id = ItemDatabase.Instance != null ? ItemDatabase.Instance.GetId(payload.Item) : (ushort)0; if (id == 0) { Debug.LogError($"[Chest] '{payload.Item.ItemName}' is not in the ItemDatabase — cannot store. Run Rebuild Item Database.", this); return; } inv.WriteSlot(inventoryIndex, SlotContent.Empty); } MoveCrossServerRpc(chestIndex, inventoryIndex, chestIsSource, id, payload.Quantity, payload.Uses); } /// /// Quick transfer (right-click): sends a stack to the other container without the player picking a /// destination, so it is auto-placed into the first slot that accepts it. Deliberately a different /// operation from — here the player expressed no target. /// public void RequestQuickTransfer(SlotContainer fromContainer, int index) { PlayerInventory inv = PlayerInventory.Instance; if (inv == null) return; if (fromContainer == SlotContainer.Chest) { if (!TryGetSlot(index, 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; } QuickWithdrawServerRpc(index); return; } SlotContent content = inv.ReadSlot(index); if (content.IsEmpty) return; ushort id = ItemDatabase.Instance != null ? ItemDatabase.Instance.GetId(content.Item) : (ushort)0; if (id == 0) { Debug.LogError($"[Chest] '{content.Item.ItemName}' is not in the ItemDatabase — cannot store. Run Rebuild Item Database.", this); return; } inv.WriteSlot(index, SlotContent.Empty); QuickDepositServerRpc(id, content.Quantity, content.Uses, index); } /// /// Stores every inventory stack in the chest, auto-placing each. Safe: the server refunds whatever /// does not fit back to the slot it came from. /// public void RequestDepositAll() { PlayerInventory inv = PlayerInventory.Instance; if (inv == null) return; for (int i = 0; i < inv.InventorySize; i++) RequestQuickTransfer(SlotContainer.Inventory, i); } /// /// Takes every chest stack into the inventory. Each slot is CanFit-checked as it goes; because the /// grants come back asynchronously the checks do not 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++) RequestQuickTransfer(SlotContainer.Chest, i); } #endregion #region Server RPCs /// /// Server-side chest-to-chest move: both slots are ours, so the shared rules run straight on the /// authoritative model. /// [ServerRpc(RequireOwnership = false)] private void MoveWithinServerRpc(int fromIndex, int toIndex) { if (fromIndex < 0 || fromIndex >= slots.Length) return; if (toIndex < 0 || toIndex >= slots.Length) return; SlotContent from = ReadServerSlot(fromIndex); SlotContent to = ReadServerSlot(toIndex); if (!SlotTransfer.Move(ref from, ref to)) return; WriteServerSlot(fromIndex, from); WriteServerSlot(toIndex, to); } /// /// Server-side inventory↔chest move, both directions. The player's slot arrives as a payload (empty /// when they dragged onto an empty cell); the same rules that drive an inventory-to-inventory drag /// decide between move, merge and swap, then whatever ends up on the player's side is granted back /// into the exact slot they used. An out-of-range chest index refunds the payload rather than /// swallowing it. /// [ServerRpc(RequireOwnership = false)] private void MoveCrossServerRpc(int chestIndex, int inventoryIndex, bool chestIsSource, ushort id, int quantity, int uses, NetworkConnection conn = null) { ItemData payloadItem = id != 0 && ItemDatabase.Instance != null ? ItemDatabase.Instance.GetItem(id) : null; SlotContent playerContent = SlotContent.Of(payloadItem, quantity, uses); if (chestIndex < 0 || chestIndex >= slots.Length) { GrantBack(conn, playerContent, inventoryIndex); return; } SlotContent chestContent = ReadServerSlot(chestIndex); if (chestIsSource) SlotTransfer.Move(ref chestContent, ref playerContent); else SlotTransfer.Move(ref playerContent, ref chestContent); WriteServerSlot(chestIndex, chestContent); GrantBack(conn, playerContent, inventoryIndex); } /// /// Server-side quick deposit: auto-places the incoming stack and refunds any leftover to the slot /// it was taken from. /// [ServerRpc(RequireOwnership = false)] private void QuickDepositServerRpc(ushort id, int quantity, int uses, int originIndex, NetworkConnection conn = null) { ItemData item = ItemDatabase.Instance != null ? ItemDatabase.Instance.GetItem(id) : null; SlotContent incoming = SlotContent.Of(item, quantity, uses); if (incoming.IsEmpty) return; AutoPlace(ref incoming); GrantBack(conn, incoming, originIndex); } /// /// Server-side quick withdraw: empties the chest slot and lets the player's inventory auto-place it. /// [ServerRpc(RequireOwnership = false)] private void QuickWithdrawServerRpc(int chestIndex, NetworkConnection conn = null) { if (chestIndex < 0 || chestIndex >= slots.Length) return; SlotContent content = ReadServerSlot(chestIndex); if (content.IsEmpty) return; WriteServerSlot(chestIndex, SlotContent.Empty); GrantBack(conn, content, -1); } #endregion #region Server Helpers /// /// Auto-places a stack into the chest through the shared rule: merge into matching stacks first, /// then fill empty slots. Never swaps — the player picked no destination. Whatever does not fit is /// left in for the caller to refund. /// private void AutoPlace(ref SlotContent incoming) { for (int i = 0; i < slots.Length && !incoming.IsEmpty; i++) if (!slots[i].IsEmpty) StackIntoSlot(i, ref incoming); for (int i = 0; i < slots.Length && !incoming.IsEmpty; i++) if (slots[i].IsEmpty) StackIntoSlot(i, ref incoming); } /// /// Pushes as much of the incoming stack as one chest slot accepts, writing it back when it changed. /// private void StackIntoSlot(int index, ref SlotContent incoming) { SlotContent target = ReadServerSlot(index); if (!SlotTransfer.TryStack(ref incoming, ref target)) return; WriteServerSlot(index, target); } /// /// Reads a chest slot from the authoritative model as a container-agnostic snapshot. /// private SlotContent ReadServerSlot(int index) { InventorySlot s = slots[index]; if (s.IsEmpty) return SlotContent.Empty; return SlotContent.Of(s.ItemData, s.Quantity, s.CurrentUses); } /// /// Writes a snapshot into the authoritative model and pushes it into the replicated view in one /// step, so the two can never drift apart. Carries the remaining uses, so a worn tool stored in a /// chest comes back out just as worn. /// private void WriteServerSlot(int index, SlotContent content) { InventorySlot s = slots[index]; if (content.IsEmpty) { s.Clear(); slotViews[index] = new ChestSlotView { rawId = 0, quantity = 0, uses = -1 }; return; } s.Set(content.Item, content.Quantity, content.Uses); ushort id = ItemDatabase.Instance != null ? ItemDatabase.Instance.GetId(content.Item) : (ushort)0; slotViews[index] = new ChestSlotView { rawId = id, quantity = content.Quantity, uses = content.Uses }; } /// /// Grants a stack to the requesting player's (client-authoritative) inventory. No-op when empty. /// is the slot the player used, so a refund or a swapped-out /// stack returns exactly there instead of auto-filling the first free slot (the hotbar). Negative /// means auto-place. Always flagged as a transfer: taking a stack out of a chest is shuffling /// items between the player's own containers, not a gain, so the HUD must not announce it. /// private void GrantBack(NetworkConnection conn, SlotContent content, int preferredIndex) { if (content.IsEmpty) return; PlayerInventory inv = ResolveInventory(conn); if (inv != null) inv.GrantItemFromServer(content.Item, content.Quantity, content.Uses, preferredIndex, isTransfer: true); } /// /// 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 } }