Code/StorageContainer.cs
using Sandbox;
using System;

/// <summary>
/// A world container (chest, crate, etc.) with its own inventory grid.
/// Open it via InventoryHud.OpenContainer( this ).
/// TransferSlotToPlayer / TransferAllToPlayer are convenience helpers —
/// wire them up to your interaction system or call them from the HUD.
/// </summary>
public sealed class StorageContainer : Component
{
    [Property, Group( "Grid" )] public int Width  { get; set; } = 5;
    [Property, Group( "Grid" )] public int Height { get; set; } = 4;

    [Property, Group( "Display" )] public string ContainerName { get; set; } = "Container";

    /// <summary>
    /// Optional loot table. If set and AutoPopulate is true, the container fills
    /// itself on start when empty. Call PopulateFromLootTable() to re-roll at runtime.
    /// </summary>
    [Property, Group( "Loot" )] public LootTable LootTable { get; set; }

    /// <summary>
    /// Automatically roll the loot table on Start if the container is empty.
    /// </summary>
    [Property, Group( "Loot" )] public bool AutoPopulate { get; set; } = true;

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

    public string DataHash => _data;

    /// <summary>True when this container has a loot table — used by the HUD to style it differently.</summary>
    public bool IsLootContainer => LootTable != null;

    public InventoryContainer Container { get; private set; }

    /// <summary>Fired on all clients whenever container state changes.</summary>
    public Action OnContainerChanged;

    /// <summary>
    /// Fire this to request any listening HUD to open this container's loot panel.
    /// PlayerPickupHandler calls it on E-press. Subscribe in your HUD or game code.
    /// </summary>
    public static Action<StorageContainer> OnOpenRequested;

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

    protected override void OnStart()
    {
        Container = new InventoryContainer( Width, Height );
        Container.Deserialize( _data );

        if ( AutoPopulate && LootTable != null && IsContainerEmpty() )
            PopulateFromLootTable();
    }

    private bool IsContainerEmpty()
    {
        for ( int i = 0; i < Container.SlotCount; i++ )
            if ( !Container.GetSlot( i ).IsEmpty ) return false;
        return true;
    }

    /// <summary>
    /// Clear the container and re-roll the loot table. Safe to call at runtime.
    /// </summary>
    public void PopulateFromLootTable()
    {
        if ( LootTable == null ) return;
        Container.Clear();
        foreach ( var result in LootTable.Roll() )
            Container.AddItem( result.Item, result.Amount );
        Push();
    }

    private void OnDataChanged( string oldValue, string newValue )
    {
        Container?.Deserialize( newValue );
        OnContainerChanged?.Invoke();
    }

    // ─── Container API ───────────────────────────────────────────

    /// <summary>Add items to this container. Returns leftover that didn't fit.</summary>
    public int GiveItem( InventoryItem item, int amount = 1 )
    {
        if ( !AssertAuthority() ) return amount;
        int leftover = Container.AddItem( item, amount );
        Push();
        return leftover;
    }

    /// <summary>Remove items from this container. Returns how many were removed.</summary>
    public int TakeItem( InventoryItem item, int amount = 1 )
    {
        if ( !AssertAuthority() ) return 0;
        int removed = Container.RemoveItem( item, amount );
        Push();
        return removed;
    }

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

    public void SwapSlots( int indexA, int indexB )
    {
        if ( !AssertAuthority() ) return;
        if ( indexA < 0 || indexB < 0 || indexA >= Container.SlotCount || indexB >= Container.SlotCount ) return;
        Container.SwapSlots( indexA, indexB );
        Push();
    }

    public void Clear()
    {
        if ( !AssertAuthority() ) return;
        Container.Clear();
        Push();
    }

    // ─── Transfer Helpers ────────────────────────────────────────

    /// <summary>
    /// Move the item in the given container slot into the player's bag.
    /// Returns how many items could not fit in the player's bag (left in container).
    /// Note: requires authority on both this container and the player's InventoryComponent.
    /// </summary>
    public int TransferSlotToPlayer( int slotIndex, InventoryComponent player )
    {
        if ( !AssertAuthority() ) return 0;
        if ( player == null ) return 0;

        var slot = Container.GetSlot( slotIndex );
        if ( slot.IsEmpty ) return 0;

        var item = slot.Item;
        int qty  = slot.Quantity;

        int leftover = player.GiveItemSilent( item, qty );
        int taken    = qty - leftover;

        if ( taken > 0 )
        {
            slot.Remove( taken );
            player.ForcePushBagSync();
            Push();
        }

        return leftover;
    }

    /// <summary>
    /// Move every item from this container into the player's bag.
    /// Items that don't fit remain in the container.
    /// Syncs both the bag and container once at the end rather than per-item.
    /// </summary>
    public void TransferAllToPlayer( InventoryComponent player )
    {
        if ( !AssertAuthority() ) return;
        if ( player == null ) return;

        bool changed = false;
        for ( int i = 0; i < Container.SlotCount; i++ )
        {
            var slot = Container.GetSlot( i );
            if ( slot.IsEmpty ) continue;

            int leftover = player.GiveItemSilent( slot.Item, slot.Quantity );
            int taken    = slot.Quantity - leftover;
            if ( taken > 0 )
            {
                slot.Remove( taken );
                changed = true;
            }
        }

        if ( changed )
        {
            player.ForcePushBagSync();
            Push();
        }
    }

    /// <summary>
    /// Move an item from the player's bag into this container.
    /// If containerIndex is -1, it stacks or fills the first available slot.
    /// </summary>
    public void TransferFromPlayer( int bagIndex, InventoryComponent player, int containerIndex = -1 )
    {
        if ( !AssertAuthority() ) return;
        if ( player == null ) return;

        var bagSlot = player.Bag.GetSlot( bagIndex );
        if ( bagSlot.IsEmpty ) return;

        var item = bagSlot.Item;
        int qty  = bagSlot.Quantity;

        if ( containerIndex >= 0 && containerIndex < Container.SlotCount )
        {
            var target = Container.GetSlot( containerIndex );
            if ( target.IsEmpty || target.Item == item )
            {
                int leftover = target.Add( item, qty );
                int taken    = qty - leftover;
                if ( taken > 0 )
                {
                    bagSlot.Remove( taken );
                    player.ForcePushBagSync();
                    Push();
                }
                return;
            }
        }

        int bagLeftover = Container.AddItem( item, qty );
        int moved       = qty - bagLeftover;
        if ( moved > 0 )
        {
            bagSlot.Remove( moved );
            player.ForcePushBagSync();
            Push();
        }
    }

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

    /// <summary>Force a sync push after direct slot manipulation from the HUD.</summary>
    public void ForcePush() => Push();

    private void Push()
    {
        _data = Container.Serialize();
        OnContainerChanged?.Invoke();
    }

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