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