lib/t2_server_query.ex

defmodule T2ServerQuery do
  @moduledoc """

  Querying a Tribes 2 server actually requires sending 2 different packets to the server where the first byte is denoting the type of information we're asking for. The first is called the `info` packet which doesnt contain much more then the server name. The second is called the `status` packet which contains all the meat and potatoes.

  The `T2ServerQuery.query/3` function makes requests for both `info` and `status` and combines them into a single response for easy consumption.


  ## Installation
      def deps do
        [
          {:t2_server_query, "~> 0.1.3"}
        ]
      end

  ## Usage
      # T2ServerQuery.query("35.239.88.241", port // 28_000, timeout // 3_500)
      T2ServerQuery.query("35.239.88.241")

  ---

  """

  require Logger

  alias T2ServerQuery.PacketParser

  @doc """
  Perform a server query.  **Results should be in the form of a tuple with either `:ok` or `:error`**

      {:ok, %T2ServerQuery.QueryResult{...} }

      {:error, %T2ServerQuery.QueryResult{...} }


  ## Examples

      iex> T2ServerQuery.query("35.239.88.241")
      {:ok,
      %T2ServerQuery.QueryResult{
        bot_count: 0,
        game_type: "Classic",
        map_name: "Canker",
        max_player_count: 64,
        mission_type: "LakRabbit",
        player_count: 0,
        players: [%{}],
        server_description: "Celebrating 20 Years of Tribes2! More information in Discord. <a:playt2.com/discord>playt2.com/discord</a>",
        server_name: "Discord PUB",
        server_status: :online,
        team_count: 1,
        teams: [%{name: "Storm", score: 0}]
      }}

      iex> T2ServerQuery.query("127.0.0.1")
      {:error,
      %T2ServerQuery.QueryResult{
        bot_count: 0,
        game_type: "",
        map_name: "",
        max_player_count: 0,
        mission_type: "",
        player_count: 0,
        players: [],
        server_description: "Host unreachable, timed out.",
        server_name: "127.0.0.1:28000",
        server_status: :offline,
        team_count: 0,
        teams: []
      }}

  """
  @spec query(String.t(), integer(), integer()) :: {atom(), T2ServerQuery.QueryResult.t()}
  def query(server_ip, port \\ 28_000, timeout \\ 3_500) do
    Logger.info "query: #{server_ip}"
    case is_valid_ip?(server_ip) do
      true -> handle_query(server_ip, port, timeout)
      false ->  PacketParser.init({:error, "#{server_ip} - Invalid IP" }, nil)
    end
  end

  @spec handle_query(String.t(), integer(), integer()) :: {atom(), T2ServerQuery.QueryResult.t()}
  defp handle_query(server_ip, port, timeout) do
    {:ok, socket} = :gen_udp.open(0, [:binary, {:active, false}])

    # Convert a string ip from "127.0.0.1" into {127, 0, 0, 1}
    {:ok, s_ip} = server_ip
      |> to_charlist()
      |> :inet.parse_address()


    qry_info_packet   = <<14, 2, 1, 2, 3, 4>>
    qry_status_packet = <<18, 2, 1, 2, 3, 4>>

    # Requst info packet
    :gen_udp.send(socket, s_ip, port, qry_info_packet)
    hex_info_packet = :gen_udp.recv(socket, 0, timeout)
      |> handle_udp_response(server_ip, port)

    # Request status packet
    :gen_udp.send(socket, s_ip, port, qry_status_packet)
    hex_status_packet = :gen_udp.recv(socket, 0, timeout)
      |> handle_udp_response(server_ip, port)

    # Combine and parse results
    PacketParser.init(hex_info_packet, hex_status_packet)
  end

  @spec is_valid_ip?(any()) :: boolean()
  defp is_valid_ip?(nil), do: false
  defp is_valid_ip?(server_ip) do
    case Regex.match?(~r/^([1-2]?[0-9]{1,2}\.){3}([1-2]?[0-9]{1,2})$/, server_ip) do
      false -> false
      true -> true
    end
  end


  @spec handle_udp_response(tuple(), String.t(), integer()) :: tuple() | String.t()
  defp handle_udp_response({:ok, {_ip, port, packet}}, _server_ip, port) do
    packet
      |> Base.encode16
  end

  defp handle_udp_response({:error, :timeout}, server_ip, port) do
    Logger.error "TIMEOUT --> #{server_ip}:#{port}"
    {:error, "#{server_ip}:#{port}"}
  end



  @doc false
  def log(thing_to_log) do
    #  Just a simple debug logging util
    Logger.info(inspect thing_to_log)
    IO.puts "\n____________________________________________\n"
    thing_to_log
  end


end