mirror of
https://github.com/amineo/t2_server_query_elixir.git
synced 2026-02-22 16:13:35 +00:00
* init
This commit is contained in:
commit
3b22db761b
12 changed files with 592 additions and 0 deletions
185
lib/t2_server_query/packet_parser.ex
Normal file
185
lib/t2_server_query/packet_parser.ex
Normal 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
|
||||
65
lib/t2_server_query/query_result.ex
Normal file
65
lib/t2_server_query/query_result.ex
Normal 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
|
||||
94
lib/t2_server_query/udp_server.ex
Normal file
94
lib/t2_server_query/udp_server.ex
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue