lib/t2_server_query/packet_parser.ex

defmodule T2ServerQuery.PacketParser do
  @moduledoc """
  This module does the heavy lifting with parsing a Tribes 2 query response packet.

  ## UDP Packet Anatomy

  ### Info Packet
      <<
        _header     :: size(192),
        server_name :: bitstring
      >>

  ### Status Packet
      <<
        _header :: size(48),

        game_type_length    :: little-integer,
        game_type           :: binary-size(game_type_length),
        mission_type_length :: little-integer,
        mission_type        :: binary-size(mission_type_length),
        map_name_length     :: little-integer,
        map_name            :: binary-size(map_name_length),

        _skip_a :: size(8),

        player_count     :: little-integer,
        max_player_count :: little-integer,
        bot_count        :: little-integer,

        _skip_b :: size(16),

        server_description_length :: little-integer,
        server_description        :: binary-size(server_description_length),

        _skip_c :: size(16),

        team_count :: binary-size(1),

        rest :: bitstring
      >>

  Notice the `_skip_(a|b|c)` mappings. I havn't quite figured out what they refer to yet but they don't seem that important. They likely relate to a few server flags like `tournament_mode`, `cpu_speed`, `is_linux`.

  Refer to `T2ServerQuery.QueryResult` for what a typical struct would look like.

  """


  alias T2ServerQuery.QueryResult

  @doc """
    This function expects both an `info` and `status` packet to be passed in that is in a `Base.encode16` format.
    Normally you wouldn't need to run this function manually since it's called in a pipeline from the main `T2ServerQuery.query`

  """
  @spec init({:error, String.t()}, any()) :: {:error, map()}
  def init({:error, host}, _) do
    results = %QueryResult{}

    {:error,
      %{results |
        server_status: :offline,
        server_name: host,
        server_description: "Host unreachable, timed out."
      }
    }
  end

  @spec init(binary(), binary()) :: {:ok, QueryResult.t()}
  def init(info_packet, status_packet) when is_binary(info_packet) and is_binary(status_packet) do

    info_results = info_packet
      |> decode_clean_packet()
      |> handle_info_packet()


    status_results = status_packet
      |> decode_clean_packet()
      |> handle_status_packet()
      |> parse_player_team_scores()


    pack_results({:ok, status_results, info_results})
  end

  @spec pack_results({:ok, map(), map()}) :: {:ok, QueryResult.t()}
  defp pack_results({:ok, status_results, info_results}) do
    results = %QueryResult{}

    {:ok,
      %{results |
        server_status:      :online,
        server_name:        info_results.server_name,
        game_type:          status_results.game_type,
        mission_type:       status_results.mission_type,
        map_name:           status_results.map_name,
        player_count:       status_results.player_count,
        max_player_count:   status_results.max_player_count,
        bot_count:          status_results.bot_count,
        server_description: status_results.server_description,
        team_count:         status_results.team_count,
        teams:              status_results.teams,
        players:            status_results.players
      }
    }
  end

  # Info packet structure
  defp handle_info_packet({:ok, info_packet}) do
    <<
      _header     :: size(192),
      server_name :: bitstring
    >> = info_packet

    %{server_name: server_name}
  end


  # Status packet structure
  defp handle_status_packet({:ok, status_packet}) do
    #IO.inspect status_packet, limit: :infinity
    <<
      _header :: size(48),

      game_type_length    :: little-integer,
      game_type           :: binary-size(game_type_length),
      mission_type_length :: little-integer,
      mission_type        :: binary-size(mission_type_length),
      map_name_length     :: little-integer,
      map_name            :: binary-size(map_name_length),

      _skip_a :: size(8),

      player_count     :: little-integer,
      max_player_count :: little-integer,
      bot_count        :: little-integer,

      _skip_b :: size(16),

      server_description_length :: little-integer,
      server_description        :: binary-size(server_description_length),

      _skip_c :: size(16),

      team_count :: binary-size(1),

      rest :: bitstring
    >> =  status_packet


    %{
      game_type_length:           game_type_length,
      game_type:                  game_type,
      mission_type_length:        mission_type_length,
      mission_type:               mission_type,
      map_name_length:            map_name_length,
      map_name:                   map_name,
      player_count:               player_count,
      max_player_count:           max_player_count,
      bot_count:                  bot_count,
      server_description_length:  server_description_length,
      server_description:         server_description,
      team_count:                 String.to_integer(team_count),
      teams:                      [],
      players:                    [],
      data:                       rest
    }
  end



  # Take the ..rest of the status packet and parse out the team and player scores
  defp parse_player_team_scores(packet) do
    ## Break the status query packet into multiple parts
    ## raw_game_info contains the map, gametype, mod, and description
    ## raw_players_info contains players, assigned team and score
    [raw_team_scores | raw_players_info] = String.split(packet.data, "\n#{packet.player_count}", trim: true)

    pack_teams = raw_team_scores
      |> String.trim_leading
      |> String.split("\n")
      |> Enum.map(&parse_team_scores(&1))
      |> Enum.to_list


    pack_players = raw_players_info
      |> clean_player_info()
      |> Enum.map(&parse_player_scores(&1))
      |> Enum.to_list

    # We're done parsing the data key so we can remove it from our compiled struct
    cleaned_packet = Map.delete(packet, :data)
    %{cleaned_packet | teams: pack_teams, players: pack_players }
  end


  # Convert player array into a map
  # parse_player_scores(["Inferno", "305"])
  # > %{team: "Inferno", score: 305}
  defp parse_team_scores(raw_team_scores) do
    Enum.zip([:name, :score], String.split(raw_team_scores, "\t"))
      |> Map.new
      |> convert_score()
  end


  # Convert player array into a map
  # parse_player_scores(["Anthony", "Storm", "100"])
  # > %{player: "ElKapitan ", score: 100, team: "Inferno"}
  defp parse_player_scores(player) do
    Enum.zip([:player, :team, :score], String.split(player, "\t", trim: true))
      |> Map.new
      |> convert_score()
  end

  # Clean and spaces that might be in the packet for odd reason
  defp decode_clean_packet(packet) do
    packet
      |> String.replace(" ", "")
      |> Base.decode16(case: :mixed)
  end

  # Convert string scores into integers
  defp convert_score(%{score: score} = data) when is_binary(score) and not is_nil(data), do: %{data | score: String.to_integer(score)}
  defp convert_score(%{score: score} = data) when is_integer(score) and not is_nil(data), do: data
  defp convert_score(data), do: data

  # Strip all non-printable UTF chars but preserve spaces, tabs and new-lines
  defp clean_player_info(raw_players_info) do
    Regex.replace(~r/(*UTF)[^\w\ \t\n\/*+-]+/, List.to_string(raw_players_info), "")
      |> String.trim_leading
      |> String.split("\n")
  end

end