Code/InventoryComponent.cs
using Sandbox;
using System;

public sealed class InventoryComponent : Component
{
    // ─── Inspector Properties ────────────────────────────────────

    [Property, Group( "Grid" )]
    public int BagWidth { get; set; } = 6;

    [Property, Group( "Grid" )]
    public int BagHeight { get; set; } = 5;

    [Property, Group( "Hotbar" )]
    public bool UseHotbar { get; set; } = true;

    [Property, Group( "Equipment" )]
    public bool UseEquipment { get; set; } = true;

    // ─── Feature Toggles ─────────────────────────────────────────

    [Property, Group( "Features" )]
    public bool EnableDestroyConfirm { get; set; } = true;

    [Property, Group( "Features" )]
    public bool EnableWeight { get; set; } = true;

    [Property, Group( "Features" )]
    public bool EnableDurability { get; set; } = true;

    [Property, Group( "Features" )]
    public bool EnableEquipmentStats { get; set; } = true;

    [Property, Group( "Features" )]
    public bool EnableCategories { get; set; } = true;

    [Property, Group( "Features" )]
    public bool EnableRarity { get; set; } = true;

    [Property, Group( "Features" )]
    public bool EnableCrafting { get; set; } = true;

    [Property, Group( "Features" )]
    public bool EnableQuickStack { get; set; } = true;

    [Property, Group( "Features" )]
    public bool EnableSearch { get; set; } = true;

    [Property, Group( "Features" )]
    public bool EnableSorting { get; set; } = true;

    [Property, Group( "Category Visibility" )]
    public List<ItemCategory> VisibleCategories { get; set; } = new()
    {
        ItemCategory.Misc, ItemCategory.Weapon, ItemCategory.Armor,
        ItemCategory.Consumable, ItemCategory.Material, ItemCategory.Tool,
        ItemCategory.Key, ItemCategory.Ammo, ItemCategory.Quest
    };

    // ─── Synced State ────────────────────────────────────────────

    [Sync, Change( nameof(OnBagDataChanged) )]
    private string _bagData { get; set; } = "";

    [Sync, Change( nameof(OnEquipDataChanged) )]
    private string _equipData { get; set; } = "";

    [Sync] public int HotbarActiveIndex { get; private set; } = 0;

    // ─── Public State ────────────────────────────────────────────

    /// <summary>Serialized bag string — exposed for BuildHash in InventoryHud. Do not mutate directly.</summary>
    public string BagDataHash => _bagData;

    /// <summary>Serialized equipment string — exposed for BuildHash in InventoryHud. Do not mutate directly.</summary>
    public string EquipDataHash => _equipData;

    /// <summary>The player's bag grid container.</summary>
    public InventoryContainer Bag { get; private set; }

    /// <summary>The player's equipment slots. Null if UseEquipment is false.</summary>
    public EquipmentSlots Equipment { get; private set; }

    /// <summary>
    /// Total grid height including hotbar row if enabled.
    /// </summary>
    public int TotalHeight => BagHeight + ( UseHotbar ? 1 : 0 );

    /// <summary>
    /// The first slot index of the hotbar row (last row of the grid).
    /// Returns -1 if hotbar is disabled.
    /// </summary>
    public int HotbarStartIndex => UseHotbar
        ? BagWidth * BagHeight
        : -1;

    /// <summary>
    /// The currently active hotbar slot, or null if hotbar is disabled.
    /// </summary>
    public InventorySlot ActiveHotbarSlot
    {
        get
        {
            if ( !UseHotbar ) return null;
            return Bag.GetSlot( HotbarStartIndex + HotbarActiveIndex );
        }
    }

    // ─── Events ──────────────────────────────────────────────────

    /// <summary>
    /// Fired on all clients whenever any inventory state changes.
    /// Hook into this to update custom UI or game logic.
    /// </summary>
    public Action OnInventoryChanged;

    // ─── Lifecycle ───────────────────────────────────────────────

    protected override void OnStart()
    {
        Bag = new InventoryContainer( BagWidth, TotalHeight );

        if ( UseEquipment )
            Equipment = new EquipmentSlots();

        RebuildFromSyncData();
    }

    // ─── Sync Callbacks ──────────────────────────────────────────
    // ─── Force Sync (called by UI for direct slot manipulation) ──

public void ForcePushBagSync()
{
    AssertAuthority();
    PushBagSync();
}

public void ForcePushEquipSync()
{
    AssertAuthority();
    PushEquipSync();
}
    private void OnBagDataChanged( string oldValue, string newValue )
    {
        Bag?.Deserialize( newValue );
        OnInventoryChanged?.Invoke();
    }

    private void OnEquipDataChanged( string oldValue, string newValue )
    {
        if ( !UseEquipment || Equipment == null ) return;
        Equipment.Deserialize( newValue );
        OnInventoryChanged?.Invoke();
    }

    // ─── Bag API ─────────────────────────────────────────────────

    /// <summary>
    /// Add items to the inventory. Returns leftover amount that didn't fit.
    /// Only call on the owning client or server.
    /// </summary>
    public int GiveItem( InventoryItem item, int amount = 1 )
    {
        if ( !AssertAuthority() ) return amount;

        int toAdd = amount;
        if ( EnableWeight && MaxWeight > 0f && item.Weight > 0f )
        {
            float available  = MaxWeight - CurrentWeight;
            int maxByWeight  = (int)( available / item.Weight );
            toAdd = Math.Min( toAdd, Math.Max( 0, maxByWeight ) );
        }

        if ( toAdd <= 0 ) return amount;

        int bagLeftover   = Bag.AddItem( item, toAdd );
        int actuallyAdded = toAdd - bagLeftover;
        PushBagSync();
        return amount - actuallyAdded;
    }

    /// <summary>
    /// Remove items from the inventory. Returns how many were actually removed.
    /// </summary>
    public int TakeItem( InventoryItem item, int amount = 1 )
    {
        if ( !AssertAuthority() ) return 0;
        int removed = Bag.RemoveItem( item, amount );
        PushBagSync();
        return removed;
    }

    public bool HasItem( InventoryItem item, int amount = 1 )
        => Bag.HasItem( item, amount );

    /// <summary>
    /// Add items directly to the bag without triggering a sync push.
    /// Call ForcePushBagSync() after a batch of these to sync once.
    /// </summary>
    internal int GiveItemSilent( InventoryItem item, int amount = 1 )
    {
        if ( !AssertAuthority() ) return amount;

        int toAdd = amount;
        if ( EnableWeight && MaxWeight > 0f && item.Weight > 0f )
        {
            float available = MaxWeight - CurrentWeight;
            int maxByWeight = (int)( available / item.Weight );
            toAdd = Math.Min( toAdd, Math.Max( 0, maxByWeight ) );
        }

        if ( toAdd <= 0 ) return amount;

        int bagLeftover   = Bag.AddItem( item, toAdd );
        int actuallyAdded = toAdd - bagLeftover;
        return amount - actuallyAdded;
    }

    // ─── Quick Stack ─────────────────────────────────────────────

    /// <summary>
    /// For every item in the bag, push it into any nearby StorageContainer that
    /// already holds that item type. Only stacks into existing stacks — won't
    /// start new stacks in containers that don't already have the item.
    /// </summary>
    public void QuickStackToNearby()
    {
        if ( !AssertAuthority() ) return;
        if ( !EnableQuickStack ) return;

        bool bagChanged = false;

        foreach ( var container in Scene.GetAllComponents<StorageContainer>() )
        {
            if ( container.IsLootContainer ) continue;
            float dist = Vector3.DistanceBetween( Transform.Position, container.Transform.Position );
            if ( dist > QuickStackRadius ) continue;

            bool containerChanged = false;

            for ( int i = 0; i < Bag.SlotCount; i++ )
            {
                var slot = Bag.GetSlot( i );
                if ( slot.IsEmpty ) continue;
                if ( !container.Container.HasItem( slot.Item ) ) continue;

                int before   = slot.Quantity;
                int leftover = container.Container.AddItem( slot.Item, slot.Quantity );
                int moved    = before - leftover;

                if ( moved > 0 )
                {
                    slot.Remove( moved );
                    bagChanged       = true;
                    containerChanged = true;
                }
            }

            if ( containerChanged )
                container.ForcePush();
        }

        if ( bagChanged )
            PushBagSync(); // single sync after all containers processed
    }

    // ─── Slot Manipulation ───────────────────────────────────────

    /// <summary>
    /// Swap two slots in the bag. Works freely across hotbar and inventory rows.
    /// This is the primary method called by UI drag and drop.
    /// </summary>
    public void SwapSlots( int indexA, int indexB )
    {
        if ( !AssertAuthority() ) return;
        if ( indexA < 0 || indexB < 0 || indexA >= Bag.SlotCount || indexB >= Bag.SlotCount )
            return;
        Bag.SwapSlots( indexA, indexB );
        PushBagSync();
    }

    /// <summary>
    /// Split a stack — moves half (rounded down) from one slot into another.
    /// Target slot must be empty or contain the same item type.
    /// </summary>
    public void SplitStack( int fromIndex, int toIndex )
    {
        if ( !AssertAuthority() ) return;

        if ( fromIndex < 0 || toIndex < 0
          || fromIndex >= Bag.SlotCount || toIndex >= Bag.SlotCount )
            return;

        var from = Bag.GetSlot( fromIndex );
        var to   = Bag.GetSlot( toIndex );

        if ( from.IsEmpty || from.Quantity < 2 ) return;
        if ( !to.IsEmpty && to.Item != from.Item ) return;

        int splitAmount = from.Quantity / 2;
        from.Remove( splitAmount );
        to.Add( from.Item, splitAmount );

        PushBagSync();
    }

    /// <summary>
    /// Merge as much of fromIndex into toIndex as toIndex's MaxStack allows.
    /// Both slots must have the same item type. If fromIndex empties out, the slot is cleared.
    /// </summary>
    public void MergeStacks( int fromIndex, int toIndex )
    {
        if ( !AssertAuthority() ) return;

        if ( fromIndex < 0 || toIndex < 0
          || fromIndex >= Bag.SlotCount || toIndex >= Bag.SlotCount )
            return;

        var from = Bag.GetSlot( fromIndex );
        var to   = Bag.GetSlot( toIndex );

        if ( from.IsEmpty || to.IsEmpty || from.Item != to.Item ) return;

        int maxStack = from.Item.MaxStack;
        int space = maxStack - to.Quantity;
        int move = Math.Min( space, from.Quantity );

        if ( move <= 0 ) return;

        to.Add( from.Item, move );
        from.Remove( move );

        PushBagSync();
    }

    // ─── Sort ────────────────────────────────────────────────────

    /// <summary>
    /// Sort the bag by Category (grouped) then Name (alphabetical) then Rarity (descending).
    /// </summary>
    public void SortBag()
    {
        if ( !AssertAuthority() ) return;
        Bag.SortBy( ( a, b ) =>
        {
            int c = a.Item.Category.CompareTo( b.Item.Category );
            if ( c != 0 ) return c;
            int n = string.Compare( a.Item.ItemName, b.Item.ItemName, StringComparison.OrdinalIgnoreCase );
            if ( n != 0 ) return n;
            return b.Item.Rarity.CompareTo( a.Item.Rarity );
        } );
        PushBagSync();
    }

    // ─── Weight / Encumbrance ─────────────────────────────────────

    [Property, Group( "Quick Stack" )]
    public float QuickStackRadius { get; set; } = 400f;

    [Property, Group( "Weight" )]
    public float MaxWeight { get; set; } = 0f; // 0 = unlimited

    public float CurrentWeight
    {
        get
        {
            float total = 0f;
            for ( int i = 0; i < Bag.SlotCount; i++ )
            {
                var s = Bag.GetSlot( i );
                if ( !s.IsEmpty && s.Item.Weight > 0f )
                    total += s.Item.Weight * s.Quantity;
            }
            if ( UseEquipment && Equipment != null )
            {
                foreach ( var kvp in Equipment.AllSlots() )
                {
                    var s = kvp.Value;
                    if ( !s.IsEmpty && s.Item.Weight > 0f )
                        total += s.Item.Weight * s.Quantity;
                }
            }
            return total;
        }
    }

    public bool IsOverEncumbered => MaxWeight > 0f && CurrentWeight > MaxWeight;

    public Action<float, float> OnEncumbranceChanged; // current, max

    // ─── Durability ───────────────────────────────────────────────

    public Action<InventoryItem, int> OnItemDamaged;    // item, remainingDurability
    public Action<InventoryItem> OnItemBroken;          // item (durability hit 0)

    /// <summary>
    /// Damage an item in the bag. If durability reaches 0, the item is destroyed.
    /// </summary>
    public void DamageItem( int bagIndex, float amount )
    {
        if ( !AssertAuthority() ) return;
        if ( !EnableDurability ) return;
        var slot = Bag.GetSlot( bagIndex );
        if ( slot.IsEmpty || slot.Durability < 0 ) return;

        slot.Durability = Math.Max( 0, slot.Durability - amount );
        PushBagSync();

        OnItemDamaged?.Invoke( slot.Item, (int)slot.Durability );

        if ( slot.Durability <= 0 )
        {
            OnItemBroken?.Invoke( slot.Item );
            slot.Clear();
            PushBagSync();
        }
    }

    /// <summary>
    /// Damage an equipped item.
    /// </summary>
    public void DamageEquipItem( EquipSlot slotType, float amount )
    {
        if ( !AssertAuthority() ) return;
        if ( !EnableDurability || !UseEquipment || Equipment == null ) return;
        var slot = Equipment.GetSlot( slotType );
        if ( slot == null || slot.IsEmpty || slot.Durability < 0 ) return;

        slot.Durability = Math.Max( 0, slot.Durability - amount );
        PushEquipSync();

        OnItemDamaged?.Invoke( slot.Item, (int)slot.Durability );

        if ( slot.Durability <= 0 )
        {
            OnItemBroken?.Invoke( slot.Item );
            slot.Clear();
            PushEquipSync();
        }
    }

    // ─── Equipment API ───────────────────────────────────────────

    /// <summary>
    /// Equip the item sitting in the given bag slot.
    /// Any item displaced from the equipment slot returns to the bag automatically.
    /// Does nothing if UseEquipment is false or the item has no EquipSlot set.
    /// </summary>
    public void EquipFromBag( int bagSlotIndex )
    {
        if ( !AssertAuthority() ) return;
        if ( !UseEquipment || Equipment == null ) return;

        var bagSlot = Bag.GetSlot( bagSlotIndex );
        if ( bagSlot.IsEmpty || bagSlot.Item.EquipSlot == EquipSlot.None ) return;

        var itemToEquip = bagSlot.Item;
        bagSlot.Clear();

        var displaced = Equipment.Equip( itemToEquip );
        if ( displaced != null )
            Bag.AddItem( displaced, 1 );

        PushBagSync();
        PushEquipSync();
        BroadcastStatsChanged();
    }

    /// <summary>
    /// Unequip the item in the given equipment slot and move it back to the bag.
    /// </summary>
    public void UnequipToBag( EquipSlot slotType )
    {
        if ( !AssertAuthority() ) return;
        if ( !UseEquipment || Equipment == null ) return;

        var item = Equipment.Unequip( slotType );
        if ( item != null )
            Bag.AddItem( item, 1 );

        PushBagSync();
        PushEquipSync();
        BroadcastStatsChanged();
    }

    // ─── Equipment Stats ─────────────────────────────────────────

    /// <summary>
    /// Fired whenever equipped items change and stats need to be recalculated.
    /// Subscribe to apply totals to your character (health, attack, etc.).
    /// </summary>
    public Action<Dictionary<StatType, float>> OnStatsChanged;

    /// <summary>
    /// Returns the summed value of all stat modifiers across every equipped item.
    /// Computed on demand — no sync overhead.
    /// Returns 0 if EnableEquipmentStats is false or equipment is disabled.
    /// </summary>
    public float GetTotalStat( StatType type )
    {
        if ( !EnableEquipmentStats || !UseEquipment || Equipment == null ) return 0f;
        float total = 0f;
        foreach ( var kvp in Equipment.AllSlots() )
        {
            var slot = kvp.Value;
            if ( slot.IsEmpty || slot.Item.StatModifiers == null ) continue;
            foreach ( var mod in slot.Item.StatModifiers )
                if ( mod.Type == type ) total += mod.Value;
        }
        return total;
    }

    private void BroadcastStatsChanged()
    {
        if ( !EnableEquipmentStats || OnStatsChanged == null ) return;
        var totals = new Dictionary<StatType, float>();
        foreach ( StatType t in Enum.GetValues( typeof( StatType ) ) )
        {
            float v = GetTotalStat( t );
            if ( v != 0f ) totals[t] = v;
        }
        OnStatsChanged.Invoke( totals );
    }

    // ─── Hotbar API ──────────────────────────────────────────────

    /// <summary>
    /// Set the active hotbar slot by index (0 to BagWidth - 1).
    /// </summary>
    public void SetHotbarActive( int index )
    {
        if ( !AssertAuthority() ) return;
        if ( !UseHotbar ) return;
        HotbarActiveIndex = Math.Clamp( index, 0, BagWidth - 1 );
    }

    /// <summary>
    /// Scroll the active hotbar slot. Pass -1 or +1. Wraps around.
    /// </summary>
    public void ScrollHotbar( int delta )
    {
        if ( !AssertAuthority() ) return;
        if ( !UseHotbar ) return;
        HotbarActiveIndex = ( HotbarActiveIndex + delta % BagWidth + BagWidth ) % BagWidth;
    }

    // ─── Use / Drop / Destroy API ─────────────────────────────────

    /// <summary>
    /// Fired when an item is used (context menu "Use" action).
    /// Subscribe to implement healing, ammo, key items, etc.
    /// </summary>
    public Action<InventoryItem, int> OnItemUsed;

    /// <summary>
    /// Use an item from the bag.
    /// </summary>
    public void UseItem( int bagIndex )
    {
        if ( !AssertAuthority() ) return;
        var slot = Bag.GetSlot( bagIndex );
        if ( slot.IsEmpty || !slot.Item.IsUsable ) return;

        int qty = slot.Quantity;
        OnItemUsed?.Invoke( slot.Item, qty );

        if ( OnItemUsed == null )
            Log.Warning( $"UseItem: {slot.Item.ItemName} has IsUsable=true but no OnItemUsed handler is assigned." );
    }

    /// <summary>
    /// Use an equipped item.
    /// </summary>
    public void UseEquipItem( EquipSlot slotType )
    {
        if ( !AssertAuthority() ) return;
        if ( !UseEquipment || Equipment == null ) return;
        var slot = Equipment.GetSlot( slotType );
        if ( slot == null || slot.IsEmpty || !slot.Item.IsUsable ) return;

        OnItemUsed?.Invoke( slot.Item, 1 );

        if ( OnItemUsed == null )
            Log.Warning( $"UseEquipItem: {slot.Item.ItemName} has IsUsable=true but no OnItemUsed handler is assigned." );
    }

    /// <summary>
    /// Fired when an item is dropped into the world. The default behavior
    /// spawns a world pickup — assign your own handler to customize this.
    /// </summary>
    public Action<InventoryItem, int, Vector3> OnItemDropped;

    /// <summary>
    /// Remove an item from the bag and spawn it in the world.
    /// </summary>
    public void DropItem( int bagIndex, int quantity = 1 )
    {
        if ( !AssertAuthority() ) return;
        var slot = Bag.GetSlot( bagIndex );
        if ( slot.IsEmpty ) return;

        var item = slot.Item;
        int toDrop = Math.Min( quantity, slot.Quantity );
        slot.Remove( toDrop );
        PushBagSync();

        var dropPos = DropPosition();
        OnItemDropped?.Invoke( item, toDrop, dropPos );

        if ( OnItemDropped == null )
            SpawnDropPickup( item, toDrop, dropPos );
    }

    /// <summary>
    /// Remove an equipped item and spawn it in the world.
    /// </summary>
    public void DropEquipItem( EquipSlot slotType, int quantity = 1 )
    {
        if ( !AssertAuthority() ) return;
        if ( !UseEquipment || Equipment == null ) return;
        var slot = Equipment.GetSlot( slotType );
        if ( slot == null || slot.IsEmpty ) return;

        var item = slot.Item;
        int toDrop = Math.Min( quantity, slot.Quantity );
        slot.Remove( toDrop );
        PushEquipSync();

        var dropPos = DropPosition();
        OnItemDropped?.Invoke( item, toDrop, dropPos );

        if ( OnItemDropped == null )
            SpawnDropPickup( item, toDrop, dropPos );
    }

    /// <summary>
    /// Remove an item from the bag without spawning it in the world.
    /// </summary>
    public void DestroyItem( int bagIndex, int quantity = 1 )
    {
        if ( !AssertAuthority() ) return;
        var slot = Bag.GetSlot( bagIndex );
        if ( slot.IsEmpty ) return;
        slot.Remove( Math.Min( quantity, slot.Quantity ) );
        PushBagSync();
    }

    /// <summary>
    /// Remove an equipped item without spawning it in the world.
    /// </summary>
    public void DestroyEquipItem( EquipSlot slotType, int quantity = 1 )
    {
        if ( !AssertAuthority() ) return;
        if ( !UseEquipment || Equipment == null ) return;
        var slot = Equipment.GetSlot( slotType );
        if ( slot == null || slot.IsEmpty ) return;
        slot.Remove( Math.Min( quantity, slot.Quantity ) );
        PushEquipSync();
    }

    private Vector3 DropPosition()
    {
        var cam = Scene.Camera;
        var rot = cam?.Transform.Rotation ?? Transform.Rotation;
        return Transform.Position + rot.Forward * 100f + Vector3.Up * 50f;
    }

    private void SpawnDropPickup( InventoryItem item, int quantity, Vector3 position )
    {
        var groundPos = DropToGround( position );

        if ( item.WorldPrefab != null )
        {
            var go = item.WorldPrefab.Clone();
            go.Transform.Position = groundPos;
            go.Name = $"Drop_{item.ItemName}";

            var existingPickup = go.Components.Get<ItemPickup>();
            if ( existingPickup != null )
            {
                existingPickup.Item = item;
                existingPickup.Quantity = quantity;
            }
            else
            {
                var pickup = go.Components.Create<ItemPickup>();
                pickup.Item = item;
                pickup.Quantity = quantity;
                pickup.Mode = PickupMode.Proximity;
                pickup.PickupRadius = 64f;
            }

            var prop = go.Components.Get<Prop>();
            if ( prop != null )
            {
                prop.IsStatic = false;
            }
            else
            {
                var rb = go.Components.GetOrCreate<Rigidbody>();
                rb.Gravity = true;
                rb.MotionEnabled = true;
            }
        }
        else
        {
            var go = new GameObject( true, $"Drop_{item.ItemName}" );
            go.Transform.Position = groundPos;

            var prop = go.Components.Create<Prop>();
            prop.Model = Model.Load( "models/dev/box.vmdl" );

            var pickup = go.Components.Create<ItemPickup>();
            pickup.Item = item;
            pickup.Quantity = quantity;
            pickup.Mode = PickupMode.Proximity;
            pickup.PickupRadius = 64f;
        }
    }

    private Vector3 DropToGround( Vector3 position )
    {
        var tr = Scene.Trace
            .Ray( position + Vector3.Up * 50f, position + Vector3.Down * 1000f )
            .Run();

        if ( tr.Hit )
            return tr.EndPosition + Vector3.Up * 5f;

        return position;
    }

    // ─── Crafting API ────────────────────────────────────────────

    /// <summary>
    /// Returns true if the bag contains all ingredients for the given quantity.
    /// Always returns false if EnableCrafting is off.
    /// </summary>
    public bool CanCraft( CraftingRecipe recipe, int quantity = 1 )
    {
        if ( !EnableCrafting || recipe == null || recipe.Ingredients == null ) return false;
        foreach ( var ing in recipe.Ingredients )
        {
            if ( ing.Item == null ) continue;
            if ( !Bag.HasItem( ing.Item, ing.Amount * quantity ) ) return false;
        }
        return true;
    }

    /// <summary>
    /// Returns how many times this recipe can be crafted with the current bag contents.
    /// Returns 0 if EnableCrafting is off or ingredients are missing.
    /// </summary>
    public int GetMaxCraftable( CraftingRecipe recipe )
    {
        if ( !EnableCrafting || recipe == null || recipe.Ingredients == null ) return 0;
        int max = int.MaxValue;
        foreach ( var ing in recipe.Ingredients )
        {
            if ( ing.Item == null || ing.Amount <= 0 ) continue;
            int canMake = Bag.CountItem( ing.Item ) / ing.Amount;
            if ( canMake < max ) max = canMake;
        }
        return max == int.MaxValue ? 0 : max;
    }

    /// <summary>
    /// Removes one batch of ingredients for the recipe from the bag.
    /// Returns false if ingredients are not available.
    /// </summary>
    public bool ConsumeCraftIngredients( CraftingRecipe recipe )
    {
        if ( !AssertAuthority() ) return false;
        if ( !CanCraft( recipe, 1 ) ) return false;
        foreach ( var ing in recipe.Ingredients )
        {
            if ( ing.Item == null ) continue;
            Bag.RemoveItem( ing.Item, ing.Amount );
        }
        PushBagSync();
        return true;
    }

    /// <summary>
    /// Adds the recipe's result to the bag. Call this after the craft timer completes.
    /// </summary>
    public void GiveCraftResult( CraftingRecipe recipe )
    {
        if ( !AssertAuthority() ) return;
        if ( recipe?.ResultItem == null ) return;
        GiveItem( recipe.ResultItem, recipe.ResultQuantity );
    }

    // ─── Internal ────────────────────────────────────────────────

    private void PushBagSync()
    {
        _bagData = Bag.Serialize();
        OnInventoryChanged?.Invoke();
    }

    private void PushEquipSync()
    {
        if ( !UseEquipment || Equipment == null ) return;
        _equipData = Equipment.Serialize();
        OnInventoryChanged?.Invoke();
    }

    private void RebuildFromSyncData()
    {
        Bag?.Deserialize( _bagData );

        if ( UseEquipment && Equipment != null )
            Equipment.Deserialize( _equipData );
    }

    private bool AssertAuthority()
    {
        if ( !IsProxy ) return true;
        Log.Warning( "InventoryComponent: Cannot modify inventory from a proxy. Use an RPC or run this on the owning client." );
        return false;
    }
}