PlayerInventoryInput.cs
using Sandbox;
using System;
using System.Collections.Generic;
using System.Linq;

/// <summary>
/// Owner-side inventory switching input for any player using BaseInventoryComponent.
///
/// This component deliberately knows nothing about OneMoreRoundWeapon or OMR game
/// modes. It translates configured input actions into inventory slot/cycle requests;
/// BaseInventoryComponent remains host authoritative and routes client switches to
/// the host.
/// </summary>
public sealed class PlayerInventoryInput : Component
{
	[Property, Group( "References" )]
	public BaseInventoryComponent Inventory { get; set; }

	/// <summary>
	/// Optional OMR control gate. When present, weapon switching is disabled whenever
	/// movement/gameplay controls are disabled (menus, countdown locks, etc.). Leave
	/// this null in another project to allow switching whenever this component is enabled.
	/// </summary>
	[Property, Group( "References" )]
	public PlayerMovementInput ControlsGate { get; set; }

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

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

	[Property, Group( "Input" )]
	public string SlotActionPrefix { get; set; } = "Slot";

	[Property, Group( "Input" )]
	public string PreviousSlotAction { get; set; } = "SlotPrev";

	[Property, Group( "Input" )]
	public string NextSlotAction { get; set; } = "SlotNext";

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

	[Property, Group( "Debug" )]
	public bool LogSwitches { get; set; } = false;

	protected override void OnStart()
	{
		Inventory ??=
			Components.Get<BaseInventoryComponent>();

		ControlsGate ??=
			Components.Get<PlayerMovementInput>();

		if ( Inventory is null )
		{
			Log.Warning(
				"PlayerInventoryInput could not find BaseInventoryComponent."
			);
		}
	}

	protected override void OnUpdate()
	{
		if ( IsProxy )
			return;

		if ( Components.Get<PlayerState>()?.IsBot == true )
			return;

		if ( Inventory is null )
			return;

		if (
			ControlsGate is not null &&
			!ControlsGate.ControlsEnabled
		)
			return;

		// Direct slot selection wins when multiple inventory actions happen to be
		// pressed in the same frame. This avoids issuing two host-routed switches.
		if (
			DirectSlotsEnabled &&
			TryHandleDirectSlotInput()
		)
			return;

		if ( !CyclingEnabled )
			return;

		if (
			!string.IsNullOrWhiteSpace( PreviousSlotAction ) &&
			Input.Pressed( PreviousSlotAction )
		)
		{
			CycleInventory( -1 );
			return;
		}

		if (
			!string.IsNullOrWhiteSpace( NextSlotAction ) &&
			Input.Pressed( NextSlotAction )
		)
		{
			CycleInventory( 1 );
		}
	}

	private bool TryHandleDirectSlotInput()
	{
		if ( string.IsNullOrWhiteSpace( SlotActionPrefix ) )
			return false;

		// Conventional FPS mapping:
		// Slot1 -> inventory slot 0 ... Slot9 -> slot 8, Slot0 -> slot 9.
		for ( int displayedSlot = 1; displayedSlot <= 9; displayedSlot++ )
		{
			string action =
				$"{SlotActionPrefix}{displayedSlot}";

			if ( !Input.Pressed( action ) )
				continue;

			SwitchToSlot(
				displayedSlot - 1
			);

			return true;
		}

		string zeroAction =
			$"{SlotActionPrefix}0";

		if ( Input.Pressed( zeroAction ) )
		{
			SwitchToSlot( 9 );
			return true;
		}

		return false;
	}

	private void SwitchToSlot(
		int slot
	)
	{
		if (
			slot < 0 ||
			slot >= Inventory.MaxSlots
		)
			return;

		BaseInventoryItem target =
			Inventory.GetSlot( slot );

		TrySwitchTo(
			target,
			$"slot {slot + 1}"
		);
	}

	private void CycleInventory(
		int direction
	)
	{
		if ( direction == 0 )
			return;

		List<BaseInventoryItem> candidates =
			Inventory.Items
				.Where( IsCycleCandidate )
				.ToList();

		if ( candidates.Count <= 0 )
			return;

		BaseInventoryItem active =
			Inventory.ActiveItem;

		int currentIndex =
			candidates.IndexOf( active );

		if ( currentIndex < 0 )
		{
			currentIndex =
				direction > 0
					? -1
					: 0;
		}

		for ( int attempt = 0; attempt < candidates.Count; attempt++ )
		{
			currentIndex =
				WrapIndex(
					currentIndex + Math.Sign( direction ),
					candidates.Count
				);

			BaseInventoryItem target =
				candidates[currentIndex];

			if ( target == active )
				continue;

			if (
				TrySwitchTo(
					target,
					direction > 0
						? "next"
						: "previous"
				)
			)
				return;
		}
	}

	private bool IsCycleCandidate(
		BaseInventoryItem item
	)
	{
		if ( item is null )
			return false;

		if ( item == Inventory.ActiveItem )
			return true;

		if ( !SkipUnswitchableItems )
			return true;

		return item.CanSwitchTo();
	}

	private bool TrySwitchTo(
		BaseInventoryItem target,
		string reason
	)
	{
		if ( target is null )
			return false;

		if ( target == Inventory.ActiveItem )
			return false;

		if (
			SkipUnswitchableItems &&
			!target.CanSwitchTo()
		)
			return false;

		if ( LogSwitches )
		{
			string from =
				Inventory.ActiveItem?.DisplayName ??
				"<none>";

			string to =
				target.DisplayName ??
				target.GameObject?.Name ??
				"<unnamed>";

			Log.Info(
				$"[INVENTORY INPUT] {reason} | {from} -> {to} | Slot:{target.Slot}"
			);
		}

		// BaseInventoryComponent.Switch is host authoritative and client calls are
		// routed through the host. false keeps re-selecting the active slot a no-op.
		Inventory.Switch(
			target,
			false
		);

		return true;
	}

	private static int WrapIndex(
		int index,
		int count
	)
	{
		if ( count <= 0 )
			return 0;

		index %= count;

		if ( index < 0 )
			index += count;

		return index;
	}
}