q3_servers 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.gitignore +56 -0
- data/.travis.yml +6 -0
- data/Gemfile +7 -0
- data/Gemfile.lock +42 -0
- data/LICENSE +21 -0
- data/README.md +95 -0
- data/Rakefile +10 -0
- data/bin/console +14 -0
- data/bin/setup +8 -0
- data/lib/q3_servers.rb +11 -0
- data/lib/q3_servers/list.rb +191 -0
- data/lib/q3_servers/massive_helper.rb +54 -0
- data/lib/q3_servers/player.rb +5 -0
- data/lib/q3_servers/server.rb +108 -0
- data/lib/q3_servers/server_connection.rb +118 -0
- data/lib/q3_servers/version.rb +3 -0
- data/q3_servers.gemspec +23 -0
- metadata +62 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: f82fbeb9b327a11aedb51d1ad6f3dd98d20ff7863d5d1c108e70680cfc4f3ffb
|
4
|
+
data.tar.gz: 45fa2991f473f0714823d3574a1965dc56b249fc2a7a63c1a753e15bd752bcd9
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 96f27480478d3f3443cd5451cd2876f036949037d2cc22731dc8c39734ec561d6f005430489fb133bdd286153b76289bf7d317f6d7330464ab40a0ded0d0d16b
|
7
|
+
data.tar.gz: '01168eac86e3c2bfa307c5c323b204b154e1bab4242387660504300553f9598ee19c732c9d67365c7cda751d74dca55d447c5451c2655fba6ba18b3e1eab3fd0'
|
data/.gitignore
ADDED
@@ -0,0 +1,56 @@
|
|
1
|
+
*.gem
|
2
|
+
*.rbc
|
3
|
+
/.config
|
4
|
+
/coverage/
|
5
|
+
/InstalledFiles
|
6
|
+
/pkg/
|
7
|
+
/spec/reports/
|
8
|
+
/spec/examples.txt
|
9
|
+
/test/tmp/
|
10
|
+
/test/version_tmp/
|
11
|
+
/tmp/
|
12
|
+
|
13
|
+
# Used by dotenv library to load environment variables.
|
14
|
+
# .env
|
15
|
+
|
16
|
+
# Ignore Byebug command history file.
|
17
|
+
.byebug_history
|
18
|
+
|
19
|
+
## Specific to RubyMotion:
|
20
|
+
.dat*
|
21
|
+
.repl_history
|
22
|
+
build/
|
23
|
+
*.bridgesupport
|
24
|
+
build-iPhoneOS/
|
25
|
+
build-iPhoneSimulator/
|
26
|
+
|
27
|
+
## Specific to RubyMotion (use of CocoaPods):
|
28
|
+
#
|
29
|
+
# We recommend against adding the Pods directory to your .gitignore. However
|
30
|
+
# you should judge for yourself, the pros and cons are mentioned at:
|
31
|
+
# https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control
|
32
|
+
#
|
33
|
+
# vendor/Pods/
|
34
|
+
|
35
|
+
## Documentation cache and generated files:
|
36
|
+
/.yardoc/
|
37
|
+
/_yardoc/
|
38
|
+
/doc/
|
39
|
+
/rdoc/
|
40
|
+
|
41
|
+
## Environment normalization:
|
42
|
+
/.bundle/
|
43
|
+
/vendor/bundle
|
44
|
+
/lib/bundler/man/
|
45
|
+
|
46
|
+
# for a library or gem, you might want to ignore these files since the code is
|
47
|
+
# intended to run in multiple environments; otherwise, check them in:
|
48
|
+
# Gemfile.lock
|
49
|
+
# .ruby-version
|
50
|
+
# .ruby-gemset
|
51
|
+
|
52
|
+
# unless supporting rvm < 1.11.0 or doing something fancy, ignore this:
|
53
|
+
.rvmrc
|
54
|
+
|
55
|
+
# Used by RuboCop. Remote config files pulled in from inherit_from directive.
|
56
|
+
# .rubocop-https?--*
|
data/.travis.yml
ADDED
data/Gemfile
ADDED
data/Gemfile.lock
ADDED
@@ -0,0 +1,42 @@
|
|
1
|
+
PATH
|
2
|
+
remote: .
|
3
|
+
specs:
|
4
|
+
q3_servers (0.1.0)
|
5
|
+
|
6
|
+
GEM
|
7
|
+
remote: https://rubygems.org/
|
8
|
+
specs:
|
9
|
+
ast (2.4.2)
|
10
|
+
minitest (5.14.4)
|
11
|
+
parallel (1.20.1)
|
12
|
+
parser (3.0.1.1)
|
13
|
+
ast (~> 2.4.1)
|
14
|
+
rainbow (3.0.0)
|
15
|
+
rake (12.3.3)
|
16
|
+
regexp_parser (2.1.1)
|
17
|
+
rexml (3.2.5)
|
18
|
+
rubocop (1.17.0)
|
19
|
+
parallel (~> 1.10)
|
20
|
+
parser (>= 3.0.0.0)
|
21
|
+
rainbow (>= 2.2.2, < 4.0)
|
22
|
+
regexp_parser (>= 1.8, < 3.0)
|
23
|
+
rexml
|
24
|
+
rubocop-ast (>= 1.7.0, < 2.0)
|
25
|
+
ruby-progressbar (~> 1.7)
|
26
|
+
unicode-display_width (>= 1.4.0, < 3.0)
|
27
|
+
rubocop-ast (1.7.0)
|
28
|
+
parser (>= 3.0.1.1)
|
29
|
+
ruby-progressbar (1.11.0)
|
30
|
+
unicode-display_width (2.0.0)
|
31
|
+
|
32
|
+
PLATFORMS
|
33
|
+
x86_64-linux
|
34
|
+
|
35
|
+
DEPENDENCIES
|
36
|
+
minitest (~> 5.0)
|
37
|
+
q3_servers!
|
38
|
+
rake (~> 12.0)
|
39
|
+
rubocop
|
40
|
+
|
41
|
+
BUNDLED WITH
|
42
|
+
2.2.17
|
data/LICENSE
ADDED
@@ -0,0 +1,21 @@
|
|
1
|
+
MIT License
|
2
|
+
|
3
|
+
Copyright (c) 2021 jpgarritano
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
7
|
+
in the Software without restriction, including without limitation the rights
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
10
|
+
furnished to do so, subject to the following conditions:
|
11
|
+
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
13
|
+
copies or substantial portions of the Software.
|
14
|
+
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,95 @@
|
|
1
|
+
# Q3Servers
|
2
|
+
|
3
|
+
This gem will help you browse through Quake3-style master servers (Protocol 68) (tested only with Urban Terror)
|
4
|
+
|
5
|
+
|
6
|
+
## Installation
|
7
|
+
|
8
|
+
Add this line to your application's Gemfile:
|
9
|
+
|
10
|
+
```ruby
|
11
|
+
gem 'q3_servers'
|
12
|
+
```
|
13
|
+
|
14
|
+
And then execute:
|
15
|
+
|
16
|
+
$ bundle install
|
17
|
+
|
18
|
+
Or install it yourself as:
|
19
|
+
|
20
|
+
$ gem install q3_servers
|
21
|
+
|
22
|
+
|
23
|
+
## Usage
|
24
|
+
```ruby
|
25
|
+
q3_urt_list = Q3Servers::List.new
|
26
|
+
|
27
|
+
#### Some defaults settings you can change
|
28
|
+
# q3_urt_list.master_address
|
29
|
+
# q3_urt_list.only_favorites = false
|
30
|
+
# q3_urt_list.timeout = 1
|
31
|
+
# q3_urt_list.master_cache_secs = 600
|
32
|
+
# q3_urt_list.info_cache_secs = 60
|
33
|
+
# q3_urt_list.debug = true
|
34
|
+
####
|
35
|
+
# q3_urt_list.add_favorite('XXX.XXX.XXX.XXX', '27960')
|
36
|
+
# q3_urt_list.add_favorite('YYY.YYY.YYY.YYY', '27961')
|
37
|
+
|
38
|
+
servers = q3_urt_list.fetch_servers({})
|
39
|
+
```
|
40
|
+
##### Threads
|
41
|
+
You can use use_threads keyword, for multi-threaded fetching (one for each server found!)
|
42
|
+
```ruby
|
43
|
+
q3_urt_list.fetch_servers({}, use_threads: true)
|
44
|
+
```
|
45
|
+
|
46
|
+
##### Filter
|
47
|
+
Filter by keys from "info" attribute in Q3Servers::Server
|
48
|
+
example:
|
49
|
+
```ruby
|
50
|
+
q3_urt_list.fetch_servers({hostname: "Arg"})
|
51
|
+
```
|
52
|
+
|
53
|
+
#### And now read information from each server
|
54
|
+
##### Examples:
|
55
|
+
```ruby
|
56
|
+
# print info from a server
|
57
|
+
servers.first.tap do |server|
|
58
|
+
puts server.info['hostname'] ## server.name_c_sanitized exclude symbols and colors from hostname field
|
59
|
+
puts server.gametype # print gametype mapped in Q3Servers::Server::GT Hash
|
60
|
+
puts server.info.keys # for more information you can access
|
61
|
+
end
|
62
|
+
|
63
|
+
# get only servers with clients
|
64
|
+
server_with_clients = servers.select { |server| server.info['clients'].to_i > 0 }
|
65
|
+
|
66
|
+
# show players
|
67
|
+
server_with_clients.each do |server|
|
68
|
+
server.info['sv_status']['players'].each do |player|
|
69
|
+
puts "#{player.name} has ping: #{player.ping} with #{player.kills} kills"
|
70
|
+
end
|
71
|
+
end
|
72
|
+
```
|
73
|
+
|
74
|
+
#### More info
|
75
|
+
|
76
|
+
You can set `only_favorites = true` for fetch only servers added with `add_favorite(host, port)` method
|
77
|
+
|
78
|
+
#### TODO
|
79
|
+
- Tests
|
80
|
+
- Pool of threads
|
81
|
+
|
82
|
+
#### This gem was built only for fun :)
|
83
|
+
|
84
|
+
##### Used in `UrbanterrorBot` on Telegram
|
85
|
+
|
86
|
+
## Development
|
87
|
+
|
88
|
+
After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
|
89
|
+
|
90
|
+
To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org).
|
91
|
+
|
92
|
+
## Contributing
|
93
|
+
|
94
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/jpgarritano/q3_servers.
|
95
|
+
|
data/Rakefile
ADDED
data/bin/console
ADDED
@@ -0,0 +1,14 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
require "bundler/setup"
|
4
|
+
require "q3_servers"
|
5
|
+
|
6
|
+
# You can add fixtures and/or initialization code here to make experimenting
|
7
|
+
# with your gem easier. You can also use a different console, if you like.
|
8
|
+
|
9
|
+
# (If you use this, don't forget to add pry to your Gemfile!)
|
10
|
+
# require "pry"
|
11
|
+
# Pry.start
|
12
|
+
|
13
|
+
require "irb"
|
14
|
+
IRB.start(__FILE__)
|
data/bin/setup
ADDED
data/lib/q3_servers.rb
ADDED
@@ -0,0 +1,11 @@
|
|
1
|
+
require "q3_servers/version"
|
2
|
+
|
3
|
+
module Q3Servers
|
4
|
+
class Error < StandardError; end
|
5
|
+
|
6
|
+
require 'q3_servers/player'
|
7
|
+
require 'q3_servers/server_connection'
|
8
|
+
require 'q3_servers/server'
|
9
|
+
require 'q3_servers/massive_helper'
|
10
|
+
require 'q3_servers/list'
|
11
|
+
end
|
@@ -0,0 +1,191 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'socket'
|
4
|
+
require 'date'
|
5
|
+
|
6
|
+
module Q3Servers
|
7
|
+
class List
|
8
|
+
PROTOCOL = 68
|
9
|
+
MAX_LENGTH = 65_536
|
10
|
+
|
11
|
+
attr_reader :master_socket, :threads, :servers, :favorites
|
12
|
+
attr_accessor :cache, :timeout, :master_updated_at, :debug, :master_cache_secs, :info_cache_secs, :only_favorites,
|
13
|
+
:master_address, :master_port
|
14
|
+
alias only_favorites? only_favorites
|
15
|
+
alias cache? cache
|
16
|
+
|
17
|
+
def initialize
|
18
|
+
@master_socket = UDPSocket.new
|
19
|
+
@servers = []
|
20
|
+
@cache = true
|
21
|
+
@timeout = 1
|
22
|
+
@master_updated_at = nil
|
23
|
+
@debug = false
|
24
|
+
@master_cache_secs = 600
|
25
|
+
@info_cache_secs = 60
|
26
|
+
@favorites = []
|
27
|
+
@master_port = 27_900
|
28
|
+
@master_address = 'master.urbanterror.info' # 51.75.224.242
|
29
|
+
@threads = []
|
30
|
+
end
|
31
|
+
|
32
|
+
def fetch_servers(filter = {}, use_threads: false)
|
33
|
+
servers_list_from_master if master_server_outdated? && !only_favorites?
|
34
|
+
fetch_info_servers(filter, use_threads)
|
35
|
+
end
|
36
|
+
|
37
|
+
def request_server_info(server, filter, use_threads)
|
38
|
+
print_debug("INFO Server: Connecting to Server id => #{server.unique_index}")
|
39
|
+
if use_threads
|
40
|
+
thread_server_info(server, filter)
|
41
|
+
else
|
42
|
+
server.request_info
|
43
|
+
end
|
44
|
+
end
|
45
|
+
|
46
|
+
def cached_info?(server)
|
47
|
+
cache? && server_info?(server) && !server_info_outdated?(server)
|
48
|
+
end
|
49
|
+
|
50
|
+
def cached_status?(server)
|
51
|
+
cache? && server_status?(server) && !server_status_outdated?(server)
|
52
|
+
end
|
53
|
+
|
54
|
+
def add_server(server)
|
55
|
+
servers << server
|
56
|
+
end
|
57
|
+
|
58
|
+
def add_favorite(ip, port)
|
59
|
+
favorites << new_favorite = Server.new(ip, port, {})
|
60
|
+
add_server(new_favorite)
|
61
|
+
end
|
62
|
+
|
63
|
+
def favorite?(server)
|
64
|
+
favorites.any? { |fav| server.unique_index == fav.unique_index }
|
65
|
+
end
|
66
|
+
|
67
|
+
private
|
68
|
+
|
69
|
+
def thread_server_info(server, filter)
|
70
|
+
@threads << Thread.new { get_server_info_status_filter(server, filter) }
|
71
|
+
rescue ThreadError => e
|
72
|
+
p "Can't create thread! => #{e.inspect}"
|
73
|
+
end
|
74
|
+
|
75
|
+
def get_server_info_status_filter(server, filter)
|
76
|
+
server.get_info_connect
|
77
|
+
server.request_and_get_status if server.filter_info(filter)
|
78
|
+
server.info
|
79
|
+
end
|
80
|
+
|
81
|
+
def server_info?(server)
|
82
|
+
!server.info.empty?
|
83
|
+
end
|
84
|
+
|
85
|
+
def server_status?(server)
|
86
|
+
server_info?(server) && !server.info_status.empty?
|
87
|
+
end
|
88
|
+
|
89
|
+
def fill_list_favorites
|
90
|
+
favorites.each do |fav|
|
91
|
+
add_server(fav)
|
92
|
+
end
|
93
|
+
end
|
94
|
+
|
95
|
+
def fill_list_master(response)
|
96
|
+
response = response.unpack('CCCCA18Ca*')
|
97
|
+
response = response[6]
|
98
|
+
until response.empty?
|
99
|
+
response = response.unpack('NnCa*')
|
100
|
+
new_sv = Server.new(to_ip(response[0]), response[1], {}) # response[2] is \\ (EOT)
|
101
|
+
add_server(new_sv)
|
102
|
+
response = response[3] # prepare for next step
|
103
|
+
end
|
104
|
+
servers
|
105
|
+
end
|
106
|
+
|
107
|
+
def fetch_info_servers(filter, use_threads)
|
108
|
+
servers.each do |server|
|
109
|
+
cache_or_request_server_info(server, filter, use_threads)
|
110
|
+
end
|
111
|
+
# wait for "info servers"
|
112
|
+
if use_threads
|
113
|
+
@threads.each(&:join) # wait for threads
|
114
|
+
else
|
115
|
+
massive_read_info_status(servers, filter)
|
116
|
+
end
|
117
|
+
destroy_socket_servers
|
118
|
+
servers.select { |server| server.filter_info(filter) }
|
119
|
+
end
|
120
|
+
|
121
|
+
def cache_or_request_server_info(server, filter, use_threads)
|
122
|
+
if cached_info?(server)
|
123
|
+
print_debug("INFO Server cached => #{server.unique_index}")
|
124
|
+
else
|
125
|
+
request_server_info(server, filter, use_threads)
|
126
|
+
end
|
127
|
+
end
|
128
|
+
|
129
|
+
def servers_list_from_master
|
130
|
+
servers.clear # #clean servers list
|
131
|
+
print_debug("Connecting to master server: cache => #{cache} | timeout => #{timeout}")
|
132
|
+
connect_request_master_socket
|
133
|
+
response_all_servers = []
|
134
|
+
loop do
|
135
|
+
response_all_servers << master_socket.recvfrom(MAX_LENGTH).first
|
136
|
+
size = response_all_servers.last.size
|
137
|
+
print_debug(size)
|
138
|
+
break if size < 1394
|
139
|
+
end
|
140
|
+
response_all_servers.each { |r_server| fill_list_master(r_server) } unless only_favorites?
|
141
|
+
self.master_updated_at = DateTime.now
|
142
|
+
end
|
143
|
+
|
144
|
+
def print_debug(info)
|
145
|
+
p info if @debug
|
146
|
+
end
|
147
|
+
|
148
|
+
def to_ip(decimal)
|
149
|
+
[decimal].pack('N').unpack('CCCC').join('.')
|
150
|
+
end
|
151
|
+
|
152
|
+
def master_server_outdated?
|
153
|
+
!master_updated_at or (DateTime.now > (master_updated_at + Rational(master_cache_secs, 86_400)))
|
154
|
+
end
|
155
|
+
|
156
|
+
def server_info_outdated?(server)
|
157
|
+
(!server_info?(server) or (DateTime.now > (server.updated_at + Rational(info_cache_secs, 86_400))))
|
158
|
+
end
|
159
|
+
|
160
|
+
def server_status_outdated?(server)
|
161
|
+
(!server_status?(server) or (DateTime.now > (server.status_updated_at + Rational(info_cache_secs, 86_400))))
|
162
|
+
end
|
163
|
+
|
164
|
+
def massive_read_info_status(servers, filter)
|
165
|
+
massive_helper = MassiveHelper.new(servers.select(&:request_status?), self)
|
166
|
+
alive_servers = massive_helper.read_info_servers(2, timeout) do |server|
|
167
|
+
server.read_info unless cached_info?(server)
|
168
|
+
end
|
169
|
+
|
170
|
+
filtered_servers = alive_servers.select { |server| server.filter_info(filter) }
|
171
|
+
filtered_servers.each(&:request_status)
|
172
|
+
|
173
|
+
massive_helper.read_status_servers(filtered_servers, 2, timeout) do |server|
|
174
|
+
server.read_status unless cached_status?(server)
|
175
|
+
end
|
176
|
+
end
|
177
|
+
|
178
|
+
def destroy_socket_servers
|
179
|
+
servers.each(&:destroy_socket)
|
180
|
+
end
|
181
|
+
|
182
|
+
def connect_request_master_socket
|
183
|
+
master_socket.connect(master_address, master_port)
|
184
|
+
master_socket.send("#{prepend_oob_data}getservers #{PROTOCOL} full empty", 0)
|
185
|
+
end
|
186
|
+
|
187
|
+
def prepend_oob_data
|
188
|
+
"\xFF" * 4
|
189
|
+
end
|
190
|
+
end
|
191
|
+
end
|
@@ -0,0 +1,54 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Q3Servers
|
4
|
+
class MassiveHelper
|
5
|
+
attr_accessor :sockets, :servers
|
6
|
+
|
7
|
+
def initialize(servers, context)
|
8
|
+
@context = context
|
9
|
+
@servers = servers.each_with_object({}) do |server, hsh|
|
10
|
+
hsh[server.unique_index] = server
|
11
|
+
end
|
12
|
+
end
|
13
|
+
|
14
|
+
def read_info_servers(max_retries, timeout, &block)
|
15
|
+
puts '======== Read Info servers ========'
|
16
|
+
read_info(servers.map { |_unique_index, server| server.socket }, max_retries, timeout, &Proc.new)
|
17
|
+
end
|
18
|
+
|
19
|
+
def read_status_servers(servers, max_retries, timeout)
|
20
|
+
puts '======== Read Status servers ========'
|
21
|
+
read_info(servers.map(&:socket), max_retries, timeout, &Proc.new)
|
22
|
+
end
|
23
|
+
|
24
|
+
def read_info(sockets, max_retries, timeout, &block)
|
25
|
+
servers_with_info = []
|
26
|
+
sockets_completed = 0
|
27
|
+
retries = 0
|
28
|
+
sockets.size.times do |_i|
|
29
|
+
break if (sockets_completed >= sockets.size) || (retries >= max_retries)
|
30
|
+
|
31
|
+
ready_sockets = IO.select(sockets, nil, nil, timeout)
|
32
|
+
if ready_sockets && (ready_sockets = ready_sockets[0])
|
33
|
+
retries = 0
|
34
|
+
ready_sockets.each do |socket|
|
35
|
+
sockets_completed += 1
|
36
|
+
server = servers[calculate_index(socket)]
|
37
|
+
block.call(server)
|
38
|
+
servers_with_info << server
|
39
|
+
end
|
40
|
+
else
|
41
|
+
retries += 1
|
42
|
+
puts "Retry n #{retries}"
|
43
|
+
end
|
44
|
+
end
|
45
|
+
servers_with_info
|
46
|
+
end
|
47
|
+
|
48
|
+
def calculate_index(socket)
|
49
|
+
# to determine which socket answered
|
50
|
+
addr = socket.peeraddr(false)
|
51
|
+
ServerConnection.new(addr.last, addr[1]).unique_index
|
52
|
+
end
|
53
|
+
end
|
54
|
+
end
|
@@ -0,0 +1,108 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Q3Servers
|
4
|
+
class Server
|
5
|
+
attr_accessor :connection, :info, :status
|
6
|
+
|
7
|
+
STATUS = %i[request request_status response response_status destroyed].freeze
|
8
|
+
GT = { '0' => 'FFA', '4' => 'TS', '7' => 'CTF', '9' => 'JMP' }.freeze
|
9
|
+
|
10
|
+
def initialize(ip, port, info)
|
11
|
+
@connection = ServerConnection.new(ip, port)
|
12
|
+
@info = info || {}
|
13
|
+
end
|
14
|
+
|
15
|
+
def name_c_sanitized
|
16
|
+
info['hostname'].gsub(/(\^[0-9]{1})/, '') if info.key?('hostname')
|
17
|
+
end
|
18
|
+
|
19
|
+
def gametype
|
20
|
+
GT.fetch(info['gametype'], '')
|
21
|
+
end
|
22
|
+
|
23
|
+
def filter_info(filter)
|
24
|
+
return false if info.empty?
|
25
|
+
return true if filter.empty?
|
26
|
+
|
27
|
+
info['hostname'] = name_c_sanitized if info.key?('hostname')
|
28
|
+
f = filter.select { |k, v| info.key?(k.to_s) and (info[k.to_s].downcase =~ /#{v.to_s.downcase}/) }
|
29
|
+
!f.empty?
|
30
|
+
end
|
31
|
+
|
32
|
+
def get_info_connect
|
33
|
+
request_info
|
34
|
+
read_info
|
35
|
+
end
|
36
|
+
|
37
|
+
def request_info
|
38
|
+
self.status = :request
|
39
|
+
puts "Requesting info to #{connection}"
|
40
|
+
connection.request_info_server
|
41
|
+
end
|
42
|
+
|
43
|
+
def request_status
|
44
|
+
self.status = :request_status
|
45
|
+
puts "Requesting status to #{connection}"
|
46
|
+
connection.request_status_server
|
47
|
+
end
|
48
|
+
|
49
|
+
def read_info
|
50
|
+
self.status = :response
|
51
|
+
self.info = connection.read_info_server
|
52
|
+
touch!
|
53
|
+
info
|
54
|
+
end
|
55
|
+
|
56
|
+
def read_status
|
57
|
+
self.status = :response_status
|
58
|
+
info['sv_status'] = connection.read_status_server
|
59
|
+
status_touch!
|
60
|
+
info['sv_status']
|
61
|
+
end
|
62
|
+
|
63
|
+
def request_and_get_status
|
64
|
+
info['sv_status'] = connection.request_and_get_server_status # step 2 more info from server
|
65
|
+
status_touch!
|
66
|
+
info['sv_status']
|
67
|
+
end
|
68
|
+
|
69
|
+
def socket
|
70
|
+
connection.socket
|
71
|
+
end
|
72
|
+
|
73
|
+
def unique_index
|
74
|
+
connection.unique_index
|
75
|
+
end
|
76
|
+
|
77
|
+
def destroy_socket
|
78
|
+
status = :destroyed
|
79
|
+
connection.socket.close
|
80
|
+
end
|
81
|
+
|
82
|
+
def updated_at
|
83
|
+
info['updated_at']
|
84
|
+
end
|
85
|
+
|
86
|
+
def status_updated_at
|
87
|
+
info.dig('sv_status', 'updated_at')
|
88
|
+
end
|
89
|
+
|
90
|
+
def info_status
|
91
|
+
info['sv_status'] || {}
|
92
|
+
end
|
93
|
+
|
94
|
+
STATUS.each do |status|
|
95
|
+
define_method("#{status}_status?") { self.status == status }
|
96
|
+
end
|
97
|
+
|
98
|
+
private
|
99
|
+
|
100
|
+
def touch!
|
101
|
+
info['updated_at'] = DateTime.now
|
102
|
+
end
|
103
|
+
|
104
|
+
def status_touch!
|
105
|
+
info['sv_status']['updated_at'] = DateTime.now
|
106
|
+
end
|
107
|
+
end
|
108
|
+
end
|
@@ -0,0 +1,118 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'digest'
|
4
|
+
module Q3Servers
|
5
|
+
class ServerConnection
|
6
|
+
attr_accessor :ip, :port, :socket, :timeout, :url_maps
|
7
|
+
|
8
|
+
MAX_LENGTH = 65_536
|
9
|
+
|
10
|
+
def initialize(ip, port, timeout = 1)
|
11
|
+
@ip = ip
|
12
|
+
@port = port
|
13
|
+
@timeout = timeout
|
14
|
+
@url_maps = 'https://www.urbanterror.info/files/static/images/levels/wide/'
|
15
|
+
end
|
16
|
+
|
17
|
+
def to_s
|
18
|
+
"#{ip}:#{port}"
|
19
|
+
end
|
20
|
+
|
21
|
+
def unique_index
|
22
|
+
Digest::MD5.hexdigest("#{ip}:#{port}")
|
23
|
+
end
|
24
|
+
|
25
|
+
def server_info_connect
|
26
|
+
request_info_server
|
27
|
+
read_info_server
|
28
|
+
end
|
29
|
+
|
30
|
+
def connect
|
31
|
+
@socket&.close
|
32
|
+
@socket = UDPSocket.new
|
33
|
+
@socket.connect(ip, port)
|
34
|
+
@socket
|
35
|
+
end
|
36
|
+
|
37
|
+
def request_info_server
|
38
|
+
connect
|
39
|
+
send_data("#{prepend_oob_data}getinfo xxx") # request step 1
|
40
|
+
end
|
41
|
+
|
42
|
+
# INFO 1/2
|
43
|
+
def read_info_server
|
44
|
+
sv_info = read_data
|
45
|
+
sv_info ? parse_sv_info(sv_info) : {} # parse step 1 info
|
46
|
+
end
|
47
|
+
|
48
|
+
# INFO 2/2
|
49
|
+
def request_and_get_server_status
|
50
|
+
# sv_status = send_and_read(prepend_oob_data + 'getstatus')
|
51
|
+
# sv_status ? parse_sv_status(sv_status) : {} # parse step 2
|
52
|
+
request_status_server
|
53
|
+
read_status_server
|
54
|
+
end
|
55
|
+
|
56
|
+
def request_status_server
|
57
|
+
send_data("#{prepend_oob_data}getstatus") # request step 2
|
58
|
+
end
|
59
|
+
|
60
|
+
# INFO 2/2
|
61
|
+
def read_status_server
|
62
|
+
sv_status = read_data
|
63
|
+
sv_status ? parse_sv_status(sv_status) : {} # parse step 2
|
64
|
+
end
|
65
|
+
|
66
|
+
private
|
67
|
+
|
68
|
+
def send_and_read(data)
|
69
|
+
send_data(data)
|
70
|
+
read_data
|
71
|
+
end
|
72
|
+
|
73
|
+
def send_data(data)
|
74
|
+
socket.send(data, 0)
|
75
|
+
end
|
76
|
+
|
77
|
+
def read_data
|
78
|
+
IO.select([socket], nil, nil, timeout)
|
79
|
+
response_info = socket.recvfrom_nonblock(MAX_LENGTH)
|
80
|
+
rescue IO::WaitReadable, Errno::ECONNREFUSED, Errno::EHOSTUNREACH
|
81
|
+
nil
|
82
|
+
else
|
83
|
+
response_info[0]
|
84
|
+
end
|
85
|
+
|
86
|
+
def parse_sv_info(sv_response)
|
87
|
+
response = sv_response.unpack('CCCCA12CCa*') # infoResponse (len 12) string
|
88
|
+
response = response[7].split('\\')
|
89
|
+
result = Hash[*response]
|
90
|
+
result['map_image_url'] = map_url(result['mapname'])
|
91
|
+
result
|
92
|
+
end
|
93
|
+
|
94
|
+
def parse_sv_status(sv_response)
|
95
|
+
response = sv_response.unpack('CCCCA14CCa*') # statusResponse (len 14) string
|
96
|
+
response = response[7].split("\n")
|
97
|
+
status = Hash[*response[0].split('\\')]
|
98
|
+
players = parse_players(response[1..-1])
|
99
|
+
status['players'] = players
|
100
|
+
status
|
101
|
+
end
|
102
|
+
|
103
|
+
def parse_players(array_str)
|
104
|
+
array_str.map do |player|
|
105
|
+
kills, ping, player_name = player.split
|
106
|
+
Player.new(player_name[1..-2], kills, ping)
|
107
|
+
end
|
108
|
+
end
|
109
|
+
|
110
|
+
def prepend_oob_data
|
111
|
+
"\xFF" * 4
|
112
|
+
end
|
113
|
+
|
114
|
+
def map_url(map_name)
|
115
|
+
"#{url_maps}#{map_name}.jpg"
|
116
|
+
end
|
117
|
+
end
|
118
|
+
end
|
data/q3_servers.gemspec
ADDED
@@ -0,0 +1,23 @@
|
|
1
|
+
require_relative 'lib/q3_servers/version'
|
2
|
+
|
3
|
+
Gem::Specification.new do |spec|
|
4
|
+
spec.name = "q3_servers"
|
5
|
+
spec.version = Q3Servers::VERSION
|
6
|
+
spec.authors = ["Juan Pablo Garritano"]
|
7
|
+
spec.email = ["tuny22@gmail.com"]
|
8
|
+
|
9
|
+
spec.summary = "Browse servers from Quake3/Urban-Terror game"
|
10
|
+
spec.homepage = "https://github.com/jpgarritano/q3_servers"
|
11
|
+
spec.required_ruby_version = Gem::Requirement.new(">= 2.3.0")
|
12
|
+
|
13
|
+
spec.metadata["homepage_uri"] = spec.homepage
|
14
|
+
spec.metadata["source_code_uri"] = "https://github.com/jpgarritano/q3_servers"
|
15
|
+
spec.metadata["changelog_uri"] = "https://github.com/jpgarritano/q3_servers"
|
16
|
+
|
17
|
+
# Specify which files should be added to the gem when it is released.
|
18
|
+
# The `git ls-files -z` loads the files in the RubyGem that have been added into git.
|
19
|
+
spec.files = Dir.chdir(File.expand_path('..', __FILE__)) do
|
20
|
+
`git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
|
21
|
+
end
|
22
|
+
spec.require_paths = ["lib"]
|
23
|
+
end
|
metadata
ADDED
@@ -0,0 +1,62 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: q3_servers
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.1.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Juan Pablo Garritano
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
date: 2021-06-22 00:00:00.000000000 Z
|
12
|
+
dependencies: []
|
13
|
+
description:
|
14
|
+
email:
|
15
|
+
- tuny22@gmail.com
|
16
|
+
executables: []
|
17
|
+
extensions: []
|
18
|
+
extra_rdoc_files: []
|
19
|
+
files:
|
20
|
+
- ".gitignore"
|
21
|
+
- ".travis.yml"
|
22
|
+
- Gemfile
|
23
|
+
- Gemfile.lock
|
24
|
+
- LICENSE
|
25
|
+
- README.md
|
26
|
+
- Rakefile
|
27
|
+
- bin/console
|
28
|
+
- bin/setup
|
29
|
+
- lib/q3_servers.rb
|
30
|
+
- lib/q3_servers/list.rb
|
31
|
+
- lib/q3_servers/massive_helper.rb
|
32
|
+
- lib/q3_servers/player.rb
|
33
|
+
- lib/q3_servers/server.rb
|
34
|
+
- lib/q3_servers/server_connection.rb
|
35
|
+
- lib/q3_servers/version.rb
|
36
|
+
- q3_servers.gemspec
|
37
|
+
homepage: https://github.com/jpgarritano/q3_servers
|
38
|
+
licenses: []
|
39
|
+
metadata:
|
40
|
+
homepage_uri: https://github.com/jpgarritano/q3_servers
|
41
|
+
source_code_uri: https://github.com/jpgarritano/q3_servers
|
42
|
+
changelog_uri: https://github.com/jpgarritano/q3_servers
|
43
|
+
post_install_message:
|
44
|
+
rdoc_options: []
|
45
|
+
require_paths:
|
46
|
+
- lib
|
47
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
48
|
+
requirements:
|
49
|
+
- - ">="
|
50
|
+
- !ruby/object:Gem::Version
|
51
|
+
version: 2.3.0
|
52
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
53
|
+
requirements:
|
54
|
+
- - ">="
|
55
|
+
- !ruby/object:Gem::Version
|
56
|
+
version: '0'
|
57
|
+
requirements: []
|
58
|
+
rubygems_version: 3.1.4
|
59
|
+
signing_key:
|
60
|
+
specification_version: 4
|
61
|
+
summary: Browse servers from Quake3/Urban-Terror game
|
62
|
+
test_files: []
|