using UnityEngine;
using UnityEngine.Events;
using FishNet.Connection;
using FishNet.Object;
using Ashwild.Network;
using Ashwild.Player;
namespace Ashwild.Inventory
{
///
/// The local player's inventory. It is client-authoritative: each player manages its own
/// slots locally (crafting, use, hotbar all stay local). The network layer is only used to
/// (a) receive items the server granted from an authoritative pickup, and (b) spawn dropped
/// world objects on the server. Only the owning client registers the Instance singleton.
///
[DisallowMultipleComponent]
public class PlayerInventory : NetworkBehaviour
{
#region Singleton
///
/// The local player's inventory (set only on the owning client).
///
public static PlayerInventory Instance { get; private set; }
#endregion
#region Serialized Fields
[Header("Inventory")]
[SerializeField] private int inventorySize = 30;
[SerializeField] private int hotbarSize = 10;
[Header("Drop")]
[SerializeField] private Transform dropOrigin;
[SerializeField] private float dropForwardDistance = 1.5f;
[Header("Events (consumed by UI)")]
public UnityEvent onSlotChanged;
public UnityEvent onSelectedSlotChanged;
public UnityEvent onItemAdded;
#endregion
#region State
private InventorySlot[] slots;
private int selectedHotbarIndex;
public int InventorySize => inventorySize;
public int HotbarSize => hotbarSize;
public int SelectedHotbarIndex => selectedHotbarIndex;
#endregion
#region Unity Lifecycle
///
/// Builds the empty slot array (local data on every copy).
///
private void Awake()
{
slots = new InventorySlot[inventorySize];
for (int i = 0; i < inventorySize; i++) slots[i] = new InventorySlot();
}
private void OnEnable()
{
PlayerEvents.HotbarSlotPressed += OnHotbarSlotPressed;
PlayerEvents.HotbarScroll += HandleHotbarScroll;
PlayerEvents.DropPressed += OnDropPressed;
PlayerEvents.ItemPickedUp += OnItemPickedUp;
}
private void OnDisable()
{
PlayerEvents.HotbarSlotPressed -= OnHotbarSlotPressed;
PlayerEvents.HotbarScroll -= HandleHotbarScroll;
PlayerEvents.DropPressed -= OnDropPressed;
PlayerEvents.ItemPickedUp -= OnItemPickedUp;
}
#endregion
#region Network Lifecycle
///
/// Registers the singleton on the owning client only (before LocalPlayerSpawned fires).
///
public override void OnStartNetwork()
{
base.OnStartNetwork();
if (base.Owner.IsLocalClient) Instance = this;
}
///
/// Clears the singleton when this player leaves the network.
///
public override void OnStopNetwork()
{
base.OnStopNetwork();
if (Instance == this) Instance = null;
}
#endregion
#region Bus Handlers
private void OnHotbarSlotPressed(int index) => SelectHotbarSlot(index);
private void HandleHotbarScroll(float dir)
{
if (PlayerEvents.IsPlacingBuild) return;
if (dir > 0f) SelectHotbarSlot((selectedHotbarIndex - 1 + hotbarSize) % hotbarSize);
else if (dir < 0f) SelectHotbarSlot((selectedHotbarIndex + 1) % hotbarSize);
}
private void OnDropPressed()
{
if (PlayerEvents.IsInventoryOpen || PlayerEvents.IsChestOpen) return;
if (PlayerStats.Instance != null && PlayerStats.Instance.IsDead) return;
DropItem(selectedHotbarIndex, 1);
}
private void OnItemPickedUp(ItemData item, int qty) => AddItem(item, qty);
#endregion
#region Network Grant / Drop
///
/// Server-side: sends a granted item to this inventory's owning client, where it is added.
/// Called after the server authorises a pickup, a cooking result or a chest withdrawal.
/// restores a specific remaining-uses value, used when a partially used
/// instance is picked back up off the ground (negative = grant at full uses).
/// is the slot the player actually aimed at (a chest item
/// dragged onto a precise inventory cell); it keeps the grant from auto-filling the first free
/// slot — which is the hotbar — when the player picked a destination. Negative = auto-place.
/// marks a container-to-container move (chest) so the HUD does not
/// announce it as a gain.
///
public void GrantItemFromServer(ItemData item, int quantity = 1, int uses = -1, int preferredIndex = -1, bool isTransfer = false)
{
if (item == null) return;
ushort id = ItemDatabase.Instance != null ? ItemDatabase.Instance.GetId(item) : (ushort)0;
if (id == 0)
{
Debug.LogError($"[PlayerInventory] '{item.ItemName}' is not in the ItemDatabase — cannot grant. Run Rebuild Item Database.", this);
return;
}
TargetGrantItem(Owner, id, quantity, uses, preferredIndex, isTransfer);
}
///
/// Runs on the owning client: resolves the granted item and adds it locally, restoring its
/// remaining uses and honouring the slot the player aimed at when one was requested.
///
[TargetRpc]
private void TargetGrantItem(NetworkConnection conn, ushort itemId, int quantity, int uses, int preferredIndex, bool isTransfer)
{
ItemData item = ItemDatabase.Instance != null ? ItemDatabase.Instance.GetItem(itemId) : null;
if (item == null)
{
Debug.LogError($"[PlayerInventory] Granted unknown item id {itemId}.", this);
return;
}
AddItem(item, quantity, uses, preferredIndex, isTransfer);
}
#endregion
#region Storage API
public InventorySlot GetSlot(int index)
{
if (index < 0 || index >= inventorySize) return null;
return slots[index];
}
public InventorySlot GetSelectedSlot() => slots[selectedHotbarIndex];
///
/// Reads a slot as a container-agnostic snapshot, including its remaining uses. This is how the
/// shared rules see an inventory slot.
///
public SlotContent ReadSlot(int index)
{
InventorySlot slot = GetSlot(index);
if (slot == null || slot.IsEmpty) return SlotContent.Empty;
return SlotContent.Of(slot.ItemData, slot.Quantity, slot.CurrentUses);
}
///
/// Writes a snapshot back into a slot and notifies the UI. Always goes through the uses-carrying
/// Set overload, so a half-worn tool stays half-worn instead of silently repairing itself.
///
public void WriteSlot(int index, SlotContent content)
{
InventorySlot slot = GetSlot(index);
if (slot == null) return;
if (content.IsEmpty) slot.Clear();
else slot.Set(content.Item, content.Quantity, content.Uses);
NotifySlotChanged(index);
}
///
/// Moves a stack between two of this inventory's slots — the drag-and-drop operation. Delegates
/// the decision (move / merge / swap) to the shared rules, so it behaves exactly like a chest
/// transfer.
///
public void MoveSlot(int fromIndex, int toIndex)
{
if (fromIndex == toIndex) return;
if (GetSlot(fromIndex) == null || GetSlot(toIndex) == null) return;
SlotContent from = ReadSlot(fromIndex);
SlotContent to = ReadSlot(toIndex);
if (!SlotTransfer.Move(ref from, ref to)) return;
WriteSlot(fromIndex, from);
WriteSlot(toIndex, to);
}
///
/// Splits a stack in half into the first empty slot — half stays, half moves. No-op on an empty
/// slot, a single unit, or a full inventory. It lives here rather than in the context menu so the
/// split goes through the same write path as every other change; done by hand from the UI it
/// bypassed NotifySlotChanged and the bus event never fired.
///
public void SplitSlot(int index)
{
SlotContent source = ReadSlot(index);
if (source.IsEmpty || source.Quantity <= 1) return;
for (int i = 0; i < inventorySize; i++)
{
if (!slots[i].IsEmpty) continue;
int moved = source.Quantity / 2;
SlotContent half = SlotContent.Of(source.Item, moved, source.Uses);
source.Quantity -= moved;
WriteSlot(index, source);
WriteSlot(i, half);
return;
}
}
///
/// Adds an item without the player picking a destination (a pickup, a craft result, a chest
/// withdrawal): merges into matching stacks first, then fills empty slots. Uses-tracked items
/// never stack, so each unit claims its own slot with its own uses bar; the optional uses value
/// restores a partially used instance (negative = full).
///
/// is the slot the player actually aimed at, tried first so a
/// targeted transfer lands where they dropped it instead of the default scan silently claiming a
/// hotbar slot. It is only a hint: whatever does not fit there falls through to normal placement,
/// so a slot that got occupied in the meantime degrades gracefully rather than losing items.
///
/// marks a stack that merely moved between the player's own
/// containers (a chest withdrawal, a refund) rather than being acquired from the world, so the
/// HUD does not announce a "gain" for shuffling items around. Craft discovery still counts it —
/// pulling an item a co-op partner left in a chest is a genuine first acquisition.
///
public bool AddItem(ItemData item, int quantity = 1, int uses = -1, int preferredIndex = -1, bool isTransfer = false)
{
if (item == null)
{
Debug.LogError("[PlayerInventory] AddItem called with no ItemData — pickup ignored.", this);
return false;
}
SlotContent incoming = SlotContent.Of(item, quantity, uses);
if (preferredIndex >= 0 && preferredIndex < inventorySize)
StackInto(preferredIndex, ref incoming);
for (int i = 0; i < inventorySize && !incoming.IsEmpty; i++)
if (!slots[i].IsEmpty) StackInto(i, ref incoming);
for (int i = 0; i < inventorySize && !incoming.IsEmpty; i++)
if (slots[i].IsEmpty) StackInto(i, ref incoming);
int added = quantity - (incoming.IsEmpty ? 0 : incoming.Quantity);
if (added > 0)
{
onItemAdded?.Invoke(item, added);
PlayerEvents.RaiseItemAdded(item, added, isTransfer);
}
return incoming.IsEmpty;
}
///
/// Pushes as much of the incoming stack as one slot accepts, through the shared auto-placement
/// rule (never swaps), and writes the slot back when it changed.
///
private void StackInto(int index, ref SlotContent incoming)
{
SlotContent target = ReadSlot(index);
if (!SlotTransfer.TryStack(ref incoming, ref target)) return;
WriteSlot(index, target);
}
///
/// Returns whether the given quantity would fit, without mutating anything. Dry-runs the exact
/// same auto-placement rule the real add uses, against copies of the slots — so this answer can
/// never drift from what would actually do, which a hand-written capacity
/// calculation eventually would.
///
public bool CanFit(ItemData item, int quantity = 1)
{
if (item == null) return false;
SlotContent incoming = SlotContent.Of(item, quantity, -1);
for (int i = 0; i < inventorySize && !incoming.IsEmpty; i++)
{
if (slots[i].IsEmpty) continue;
SlotContent target = ReadSlot(i);
SlotTransfer.TryStack(ref incoming, ref target);
}
for (int i = 0; i < inventorySize && !incoming.IsEmpty; i++)
{
if (!slots[i].IsEmpty) continue;
SlotContent target = SlotContent.Empty;
SlotTransfer.TryStack(ref incoming, ref target);
}
return incoming.IsEmpty;
}
public void RemoveItem(int index, int quantity = 1)
{
if (index < 0 || index >= inventorySize) return;
slots[index].RemoveQuantity(quantity);
NotifySlotChanged(index);
}
///
/// Consumes one use of a consumable: applies its restores once (per bite, for multi-use food)
/// then either spends a use or removes one unit. A multi-use item is kept at 0 uses (red,
/// unusable) when KeepWhenDepleted is set, otherwise it is destroyed. Ignores an already
/// depleted item.
///
public void UseItem(int index)
{
if (index < 0 || index >= inventorySize) return;
if (slots[index].IsEmpty) return;
ItemData item = slots[index].ItemData;
if (item.ItemType != ItemType.Consumable) return;
if (slots[index].IsDepleted) return;
if (PlayerStats.Instance != null)
{
if (item.HealthRestore > 0f) PlayerStats.Instance.Heal(item.HealthRestore);
if (item.HungerRestore > 0f) PlayerStats.Instance.Feed(item.HungerRestore);
if (item.ThirstRestore > 0f) PlayerStats.Instance.Drink(item.ThirstRestore);
}
PlayerEvents.RaiseItemConsumed(item);
if (item.HasUses)
{
slots[index].ConsumeUse(1);
if (slots[index].IsDepleted && !item.KeepWhenDepleted)
RemoveItem(index, 1);
else
NotifySlotChanged(index);
}
else
{
RemoveItem(index, 1);
}
}
///
/// Spends durability on the currently selected hotbar item after a successful tool hit. The
/// tool is kept at 0 uses (red, unusable until repaired) when KeepWhenDepleted is set, otherwise
/// it is destroyed. Returns false when the selected slot holds a depleted repairable tool, so
/// the caller can block the swing; true otherwise (including non-uses items).
///
public bool ConsumeSelectedToolUse(int amount = 1)
{
InventorySlot slot = slots[selectedHotbarIndex];
if (slot.IsEmpty || !slot.ItemData.HasUses) return true;
if (slot.IsDepleted) return false;
ItemData item = slot.ItemData;
slot.ConsumeUse(amount);
if (slot.IsDepleted && !item.KeepWhenDepleted)
RemoveItem(selectedHotbarIndex, 1);
else
NotifySlotChanged(selectedHotbarIndex);
return true;
}
public void DropItem(int index, int quantity = 1)
{
if (index < 0 || index >= inventorySize) return;
if (slots[index].IsEmpty) return;
ItemData item = slots[index].ItemData;
int uses = item.HasUses ? slots[index].CurrentUses : -1;
Transform origin = dropOrigin != null ? dropOrigin : transform;
if (item.WorldPrefab != null && origin != null && PickableRegistry.Instance != null)
{
Vector3 dropPos = origin.position + origin.forward * dropForwardDistance;
ushort id = ItemDatabase.Instance != null ? ItemDatabase.Instance.GetId(item) : (ushort)0;
if (id != 0)
PickableRegistry.Instance.RequestDropServerRpc(id, quantity, uses, dropPos, origin.rotation);
else
Debug.LogWarning($"[PlayerInventory] '{item.ItemName}' is not in the ItemDatabase — drop not spawned. Run Rebuild Item Database.", this);
}
RemoveItem(index, quantity);
PlayerEvents.RaiseItemDropped(item, quantity);
}
public void SelectHotbarSlot(int index)
{
if (index < 0 || index >= hotbarSize) return;
selectedHotbarIndex = index;
onSelectedSlotChanged?.Invoke(index);
PlayerEvents.RaiseSelectedHotbarSlotChanged(index);
}
public bool HasItem(ItemData item, int quantity = 1) => CountItem(item) >= quantity;
public int CountItem(ItemData item)
{
int count = 0;
for (int i = 0; i < inventorySize; i++)
if (!slots[i].IsEmpty && slots[i].ItemData == item) count += slots[i].Quantity;
return count;
}
public bool RemoveItemByData(ItemData item, int quantity)
{
if (!HasItem(item, quantity)) return false;
int remaining = quantity;
for (int i = 0; i < inventorySize && remaining > 0; i++)
{
if (!slots[i].IsEmpty && slots[i].ItemData == item)
{
int toRemove = Mathf.Min(remaining, slots[i].Quantity);
slots[i].RemoveQuantity(toRemove);
remaining -= toRemove;
NotifySlotChanged(i);
}
}
return true;
}
#endregion
#region Helpers
private void NotifySlotChanged(int index)
{
onSlotChanged?.Invoke(index);
PlayerEvents.RaiseInventorySlotChanged(index);
}
#endregion
}
}