Inventory/PlayerInventory.cs
using Sandbox;
using System;
using System.Collections.Generic;
using System.Linq;

public sealed class PlayerInventory : Component
{
	private List<InventorySlot> _slots;

	public IReadOnlyList<InventorySlot> Slots => _slots;

	[Property] private InventoryPanel InventoryPanel { get; set; }

	/// <summary>Weapon prefabs to pick from at random for the primary slot on spawn.</summary>
	[Property, Group( "Loadout" )] public List<GameObject> InitialPrimaryWeapon;

	/// <summary>Weapon prefabs to pick from at random for the secondary slot on spawn.</summary>
	[Property, Group( "Loadout" )] public List<GameObject> InitialSecondaryWeapon;

	/// <summary>Fixed items to spawn into inventory. Primary and secondary entries are skipped when a random weapon of that type already spawned.</summary>
	[Property, Group( "Loadout" )] public List<GameObject> InitialLoadout;

	protected override void OnStart()
	{
		_slots = [..GetComponentsInChildren<InventorySlot>().OrderBy(slot => slot.SlotIndex)];

		foreach ( var group in _slots.GroupBy( s => s.SlotIndex ).Where( g => g.Count() > 1 ) )
		{
			var names = string.Join( ", ", group.Select( s => s.GameObject.Name ) );
			throw new InvalidOperationException( $"Duplicate inventory slot index {group.Key} on: {names}" );
		}

		SetupInventoryPanel();
		SetupInitialLoadout();
	}

	private void SetupInventoryPanel()
	{
		if ( IsProxy )
		{
			InventoryPanel.Enabled = false;
			return;
		}

		InventoryPanel.Slots = _slots;
		InventoryPanel.Enabled = true;
	}

	private void SetupInitialLoadout()
	{
		if ( IsProxy )
			return;

		var hasPrimary = TryStowRandomFrom( InitialPrimaryWeapon );
		var hasSecondary = TryStowRandomFrom( InitialSecondaryWeapon );

		if ( InitialLoadout is null )
			return;

		foreach ( var itemPrefab in InitialLoadout )
		{
			var type = itemPrefab.Components.Get<InventoryItem>()?.Type;
			if ( hasPrimary && type == ItemType.Primary )
				continue;
			if ( hasSecondary && type == ItemType.Secondary )
				continue;

			TryStowLoadoutItem( itemPrefab );
		}
	}

	private bool TryStowRandomFrom( List<GameObject> prefabs )
	{
		if ( prefabs is not { Count: > 0 } )
			return false;

		var prefab = prefabs[Game.Random.Int( 0, prefabs.Count - 1 )];
		return TryStowLoadoutItem( prefab );
	}

	private bool TryStowLoadoutItem( GameObject itemPrefab )
	{
		if ( !itemPrefab.IsValid() )
			return false;

		var gameObject = itemPrefab.Clone();
		var item = gameObject.GetComponent<InventoryItem>();

		if ( !item.IsValid() )
		{
			Log.Error( $"Initial loadout item {itemPrefab.Name} does not have an InventoryItem component" );
			return false;
		}

		if ( !TryStow( item ) )
		{
			Log.Error( $"No free inventory slot for initial loadout item {itemPrefab.Name} of type {item.Type}" );
			return false;
		}

		gameObject.NetworkSpawn();
		return true;
	}

	/// <summary>
	/// Stows an item on the body, used by every platform. Prefers the slot the item already
	/// reserved (desktop pickups), otherwise the nearest free slot to <paramref name="nearPosition"/>
	/// (a VR hand), otherwise any free slot of the right type.
	/// </summary>
	public bool TryStow( InventoryItem item, Vector3? nearPosition = null )
	{
		if ( !item.IsValid() )
			return false;

		var slot = ResolveStowSlot( item, nearPosition );

		return slot is not null && slot.TryStow( item );
	}

	private InventorySlot ResolveStowSlot( InventoryItem item, Vector3? nearPosition )
	{
		var reservedSlot = item.Slot;
		if ( reservedSlot is not null && reservedSlot.CanAccept( item ) )
			return reservedSlot;

		if ( nearPosition.HasValue )
			return FindNearestFreeSlot( item.Type, nearPosition.Value, float.MaxValue );

		return TryGetFreeSlot( item.Type );
	}

	public InventorySlot TryGetFreeSlot( ItemType type )
	{
		return _slots.FirstOrDefault( s => s.SlotType == type && s.IsEmpty );
	}

	public InventorySlot GetSlot( InventorySlotIndex index )
	{
		return _slots.FirstOrDefault( s => s.SlotIndex == index );
	}

	public InventorySlot FindNearestFreeSlot( ItemType type, Vector3 position, float maxDistance )
	{
		InventorySlot nearest = null;
		float nearestDistance = maxDistance;

		foreach ( var slot in _slots )
		{
			if ( slot.SlotType != type || !slot.IsEmpty )
				continue;

			float distance = slot.WorldPosition.Distance( position );
			if ( distance > slot.Radius || distance > nearestDistance )
				continue;

			nearestDistance = distance;
			nearest = slot;
		}

		return nearest;
	}

	public InventoryItem GetHeldItemOfType( ItemType type )
	{
		foreach ( var slot in _slots )
		{
			var item = slot.StoredItem;
			if ( item is null || item.Type != type || item.IsStowed )
				continue;

			if ( item.IsHeld )
				return item;
		}

		foreach ( var grabPoint in Scene.GetAllComponents<GrabPoint>() )
		{
			if ( !grabPoint.IsGrabbed )
				continue;

			var item = grabPoint.Components.GetInAncestorsOrSelf<InventoryItem>();
			if ( item is not null && item.Type == type && !item.IsStowed )
				return item;
		}

		return null;
	}

	public void DropAllItems()
	{
		// Both grabbers no-op when they aren't holding anything, so the inactive
		// platform's grabber doesn't need to be filtered out here.
		foreach ( var handGrab in GameObject.Components.GetAll<HandGrab>( FindMode.InDescendants ) )
			handGrab.ForceDropHeld();

		GameObject.Components.Get<DesktopGrab>( FindMode.InDescendants )?.ForceDropHeld();

		foreach ( var slot in _slots )
		{
			var item = slot.StoredItem;
			if ( item is null || !item.IsStowed )
				continue;

			item.DropToWorld();
		}
	}
}