This commit is contained in:
Anthony Mineo 2021-10-06 17:55:39 -04:00
commit 3b22db761b
12 changed files with 592 additions and 0 deletions

View file

@ -0,0 +1,185 @@
defmodule T2ServerQuery.PacketParser do
@moduledoc """
Documentation for `T2ServerQuery.PacketParser`.
"""
alias T2ServerQuery.QueryResult
def init({:error, host}, _) do
results = %QueryResult{}
{:error,
%{results |
server_status: :offline,
server_name: host,
server_description: "Host unreachable, timed out."
}
}
end
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
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

View file

@ -0,0 +1,65 @@
defmodule T2ServerQuery.QueryResult do
@moduledoc """
Shape of the server query result struct.
%T2ServerQuery.QueryResult{
server_status: :online,
bot_count: 30,
game_type: "Classic",
map_name: "Cold as Ice [b]",
max_player_count: 64,
mission_type: "Capture the Flag",
player_count: 29,
players: [
%{player: "Rooster128", score: "0", team: "Storm"},
%{player: "sneakygnome", score: "0", team: "Inferno"},
%{player: "Waldred ", score: "0", team: "Inferno"},
%{player: "HDPTetchy ", score: "0", team: "Storm"},
%{player: "0wnj0o", score: "0", team: "Inferno"},
%{player: "idjit ", score: "0", team: "Storm"},
%{player: "JesusChrist ", score: "0", team: "Storm"},
%{player: "Sofaking--bakeD ", score: "0", team: "Inferno"},
%{player: "saKe ", score: "0", team: "Inferno"},
%{player: "ZurkinWood497", score: "0", team: "Storm"},
%{player: "TerryTC ", score: "0", team: "Inferno"},
%{player: "WankBullet ", score: "0", team: "Storm"},
%{player: "CyClones", score: "0", team: "Inferno"},
%{player: "huntergirl10", score: "0", team: "Storm"},
%{player: "ChocoTaco", score: "0", team: "Inferno"},
%{player: "Dirk", score: "0", team: "Storm"},
%{player: "Krell", score: "0", team: "Storm"},
%{player: "high5slayer", score: "0", team: "Inferno"},
%{player: "Red Fraction ", score: "0", team: "Inferno"},
%{player: "-MaLice--", score: "0", team: "Storm"},
%{player: "wiltedflower ", score: "0", team: "Inferno"},
%{player: "Glarm ", score: "0", team: "Storm"},
%{player: "AlphaSentinel", score: "0", team: "Inferno"},
%{player: "The-Punisher ", score: "0", team: "Storm"},
%{player: "2SmOkeD", score: "0", team: "Inferno"},
%{player: "iPrecision", score: "0", team: "Storm"},
%{player: "Halo 2 ", score: "0", team: "Storm"},
%{player: "Sami-FIN ", score: "0", team: "Inferno"},
%{player: "rileygarbels", score: "0", team: "Storm"}
],
server_description: "This server is using bots that are adapted to playing Classic. http://tribes2bots.byethost4.com/forum/index.php?topic=57.msg234",
server_name: "Classic Bots Server",
team_count: 2,
teams: [%{name: "Storm", score: "0"}, %{name: "Inferno", score: "0"}]
}
"""
defstruct [
server_status: :offline,
server_name: "",
game_type: "",
mission_type: "",
map_name: "",
player_count: 0,
max_player_count: 0,
bot_count: 0,
server_description: "",
team_count: 0,
teams: [],
players: []
]
end

View file

@ -0,0 +1,94 @@
defmodule T2ServerQuery.UdpServer do
@moduledoc """
Documentation for `UdpServer`.
"""
require Logger
alias T2ServerQuery.PacketParser
@doc """
Perform a server query.
Results should be in the form of a tuple
- {:ok, %T2ServerQuery.QueryResult{}}
- {:error, %T2ServerQuery.QueryResult{}}
Querying a Tribes 2 server actually requires sending 2 different packets to the server where the first byte is denoting what we're asking for in response. 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.
## Examples
iex> T2ServerQuery.UdpServer.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.UdpServer.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: []
}}
"""
def query(server_ip, port \\ 28_000, timeout \\ 3_500) do
Logger.info "query: #{server_ip}"
{: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
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
end