* Init ExDoc

This commit is contained in:
Anthony Mineo 2021-10-07 16:06:22 -04:00
parent 3b22db761b
commit 6ab3e06e78
8 changed files with 277 additions and 172 deletions

View file

@ -1,11 +1,14 @@
# T2ServerQuery
**TODO: Add description**
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
If [available in Hex](https://hex.pm/docs/publish), the package can be installed
by adding `t2_server_query` to your list of dependencies in `mix.exs`:
The package can be installed by adding `t2_server_query` to your list of dependencies in `mix.exs`:
```elixir
def deps do
@ -15,7 +18,33 @@ def deps do
end
```
Documentation can be generated with [ExDoc](https://github.com/elixir-lang/ex_doc)
and published on [HexDocs](https://hexdocs.pm). Once published, the docs can
be found at [https://hexdocs.pm/t2_server_query](https://hexdocs.pm/t2_server_query).
## Usage
```elixir
# T2ServerQuery.query("35.239.88.241", port // 28_000, timeout // 3_500)
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}]
}}
```
## Docs
The docs can be found at [https://hexdocs.pm/t2_server_query](https://hexdocs.pm/t2_server_query).
Documentation has been generated with [ExDoc](https://github.com/elixir-lang/ex_doc)
and published on [HexDocs](https://hexdocs.pm).

View file

@ -1,12 +1,119 @@
defmodule T2ServerQuery do
@moduledoc """
Documentation for `T2ServerQuery`.
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.0"}
]
end
## Usage
# T2ServerQuery.query("35.239.88.241", port // 28_000, timeout // 3_500)
T2ServerQuery.query("35.239.88.241")
---
"""
require Logger
# Just a simple debug logging util
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: []
}}
"""
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
@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

View file

@ -1,11 +1,58 @@
defmodule T2ServerQuery.PacketParser do
@moduledoc """
Documentation for `T2ServerQuery.PacketParser`.
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`
"""
def init({:error, host}, _) do
results = %QueryResult{}

View file

@ -1,51 +1,51 @@
defmodule T2ServerQuery.QueryResult do
@moduledoc """
Shape of the server query result struct.
## Struct Shape
%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"}]
}
%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 [

View file

@ -1,94 +0,0 @@
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

15
mix.exs
View file

@ -7,7 +7,17 @@ defmodule T2ServerQuery.MixProject do
version: "0.1.0",
elixir: "~> 1.12",
start_permanent: Mix.env() == :prod,
deps: deps()
deps: deps(),
# Docs
name: "T2ServerQuery",
description: "Query any Tribes 2 server and retrieve the current map, players, team scores and more!",
source_url: "https://github.com/amineo/t2_server_query_elixir",
homepage_url: "https://github.com/amineo/t2_server_query_elixir",
docs: [
main: "T2ServerQuery",
extras: ["README.md"]
]
]
end
@ -21,7 +31,8 @@ defmodule T2ServerQuery.MixProject do
# Run "mix help deps" to learn about dependencies.
defp deps do
[
{:credo, "~> 1.5"}
{:credo, "~> 1.5"},
{:ex_doc, "~> 0.24", only: :dev, runtime: false}
]
end
end

View file

@ -1,6 +1,12 @@
%{
"bunt": {:hex, :bunt, "0.2.0", "951c6e801e8b1d2cbe58ebbd3e616a869061ddadcc4863d0a2182541acae9a38", [:mix], [], "hexpm", "7af5c7e09fe1d40f76c8e4f9dd2be7cebd83909f31fee7cd0e9eadc567da8353"},
"credo": {:hex, :credo, "1.5.6", "e04cc0fdc236fefbb578e0c04bd01a471081616e741d386909e527ac146016c6", [:mix], [{:bunt, "~> 0.2.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2.8", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "4b52a3e558bd64e30de62a648518a5ea2b6e3e5d2b164ef5296244753fc7eb17"},
"earmark_parser": {:hex, :earmark_parser, "1.4.16", "607709303e1d4e3e02f1444df0c821529af1c03b8578dfc81bb9cf64553d02b9", [:mix], [], "hexpm", "69fcf696168f5a274dd012e3e305027010658b2d1630cef68421d6baaeaccead"},
"ex_doc": {:hex, :ex_doc, "0.25.3", "3edf6a0d70a39d2eafde030b8895501b1c93692effcbd21347296c18e47618ce", [:mix], [{:earmark_parser, "~> 1.4.0", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1", [hex: :makeup_erlang, repo: "hexpm", optional: false]}], "hexpm", "9ebebc2169ec732a38e9e779fd0418c9189b3ca93f4a676c961be6c1527913f5"},
"file_system": {:hex, :file_system, "0.2.10", "fb082005a9cd1711c05b5248710f8826b02d7d1784e7c3451f9c1231d4fc162d", [:mix], [], "hexpm", "41195edbfb562a593726eda3b3e8b103a309b733ad25f3d642ba49696bf715dc"},
"jason": {:hex, :jason, "1.2.2", "ba43e3f2709fd1aa1dce90aaabfd039d000469c05c56f0b8e31978e03fa39052", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "18a228f5f0058ee183f29f9eae0805c6e59d61c3b006760668d8d18ff0d12179"},
"makeup": {:hex, :makeup, "1.0.5", "d5a830bc42c9800ce07dd97fa94669dfb93d3bf5fcf6ea7a0c67b2e0e4a7f26c", [:mix], [{:nimble_parsec, "~> 0.5 or ~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "cfa158c02d3f5c0c665d0af11512fed3fba0144cf1aadee0f2ce17747fba2ca9"},
"makeup_elixir": {:hex, :makeup_elixir, "0.15.1", "b5888c880d17d1cc3e598f05cdb5b5a91b7b17ac4eaf5f297cb697663a1094dd", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.1", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "db68c173234b07ab2a07f645a5acdc117b9f99d69ebf521821d89690ae6c6ec8"},
"makeup_erlang": {:hex, :makeup_erlang, "0.1.1", "3fcb7f09eb9d98dc4d208f49cc955a34218fc41ff6b84df7c75b3e6e533cc65f", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "174d0809e98a4ef0b3309256cbf97101c6ec01c4ab0b23e926a9e17df2077cbb"},
"nimble_parsec": {:hex, :nimble_parsec, "1.1.0", "3a6fca1550363552e54c216debb6a9e95bd8d32348938e13de5eda962c0d7f89", [:mix], [], "hexpm", "08eb32d66b706e913ff748f11694b17981c0b04a33ef470e33e11b3d3ac8f54b"},
}

View file

@ -1,8 +1,7 @@
defmodule T2ServerQueryTest do
use ExUnit.Case, async: true
alias T2ServerQuery.UdpServer
doctest T2ServerQuery
# doctest T2ServerQuery
test "Gracefully handle timeouts and unreachable servers" do
@ -10,7 +9,7 @@ defmodule T2ServerQueryTest do
timeout = 250
port = Enum.random(28_000..28_999)
{:error, result} = T2ServerQuery.UdpServer.query("127.0.0.1", port, timeout)
{:error, result} = T2ServerQuery.query("127.0.0.1", port, timeout)
|> T2ServerQuery.log
assert result.server_status == :offline
@ -21,9 +20,9 @@ defmodule T2ServerQueryTest do
test "Live test a number of Tribes 2 servers" do
tasks = [
Task.async(T2ServerQuery.UdpServer, :query, ["35.239.88.241"]),
Task.async(T2ServerQuery.UdpServer, :query, ["97.99.172.12", 28_001]),
Task.async(T2ServerQuery.UdpServer, :query, ["67.222.138.13"])
Task.async(T2ServerQuery, :query, ["35.239.88.241"]),
Task.async(T2ServerQuery, :query, ["97.99.172.12", 28_001]),
Task.async(T2ServerQuery, :query, ["67.222.138.13"])
]
server_list = Task.yield_many(tasks)
@ -50,28 +49,28 @@ end
#qry_test = T2ServerQuery.UdpServer.query("127.0.0.1")
#qry_test = T2ServerQuery.query("127.0.0.1")
#IO.inspect qry_test
#qry_test2 = T2ServerQuery.UdpServer.query("35.239.88.241")
#qry_test2 = T2ServerQuery.query("35.239.88.241")
#IO.inspect qry_test2
# tasks = [
# Task.async(T2ServerQuery.UdpServer, :query, ["127.0.0.1"]),
# Task.async(T2ServerQuery.UdpServer, :query, ["35.239.88.241"]),
# Task.async(T2ServerQuery.UdpServer, :query, ["97.99.172.12", 28001]),
# Task.async(T2ServerQuery.UdpServer, :query, ["67.222.138.13"]),
# Task.async(T2ServerQuery.UdpServer, :query, ["91.55.51.94"]),
# Task.async(T2ServerQuery, :query, ["127.0.0.1"]),
# Task.async(T2ServerQuery, :query, ["35.239.88.241"]),
# Task.async(T2ServerQuery, :query, ["97.99.172.12", 28001]),
# Task.async(T2ServerQuery, :query, ["67.222.138.13"]),
# Task.async(T2ServerQuery, :query, ["91.55.51.94"]),
# ]
# IO.inspect Task.yield_many(tasks)
# task0 = Task.async(T2ServerQuery.UdpServer, :query, ["127.0.0.1"])
# task1 = Task.async(T2ServerQuery.UdpServer, :query, ["35.239.88.241"])
# task2 = Task.async(T2ServerQuery.UdpServer, :query, ["97.99.172.12", 28001])
# task3 = Task.async(T2ServerQuery.UdpServer, :query, ["67.222.138.13"])
# task4 = Task.async(T2ServerQuery.UdpServer, :query, ["91.55.51.94"])
# task0 = Task.async(T2ServerQuery, :query, ["127.0.0.1"])
# task1 = Task.async(T2ServerQuery, :query, ["35.239.88.241"])
# task2 = Task.async(T2ServerQuery, :query, ["97.99.172.12", 28001])
# task3 = Task.async(T2ServerQuery, :query, ["67.222.138.13"])
# task4 = Task.async(T2ServerQuery, :query, ["91.55.51.94"])
# # res4 = Task.await(task4)
# # IO.inspect res4.server_name
@ -87,7 +86,7 @@ end
# IO.inspect res3.server_name
# IO.inspect res4.server_name
# T2ServerQuery.UdpServer.query("35.239.88.241")
# T2ServerQuery.UdpServer.query("97.99.172.12", 28001)
# T2ServerQuery.UdpServer.query("67.222.138.13")
# T2ServerQuery.UdpServer.query("91.55.51.94")
# T2ServerQuery.query("35.239.88.241")
# T2ServerQuery.query("97.99.172.12", 28001)
# T2ServerQuery.query("67.222.138.13")
# T2ServerQuery.query("91.55.51.94")