gamequery 0.1.1
Sign up to get free protection for your applications and to get access to all the features.
- data/bin/gamequerytool +566 -0
- data/lib/gamequery.rb +775 -0
- metadata +54 -0
data/bin/gamequerytool
ADDED
@@ -0,0 +1,566 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
################################################################
|
3
|
+
#
|
4
|
+
# gamequerytool - query game servers
|
5
|
+
#
|
6
|
+
# (C) 2006 Erik Hollensbe <erik@hollensbe.org>, License details below
|
7
|
+
#
|
8
|
+
# Use 'gamequerytool -h' for usage instructions.
|
9
|
+
#
|
10
|
+
# The compilation of software known as gamequerytool is distributed under the
|
11
|
+
# following terms:
|
12
|
+
# Copyright (C) 2005-2006 Erik Hollensbe. All rights reserved.
|
13
|
+
#
|
14
|
+
# Redistribution and use in source form, with or without
|
15
|
+
# modification, are permitted provided that the following conditions
|
16
|
+
# are met:
|
17
|
+
# 1. Redistributions of source code must retain the above copyright notice,
|
18
|
+
# this list of conditions and the following disclaimer.
|
19
|
+
#
|
20
|
+
# THIS SOFTWARE IS PROVIDED BY AUTHOR AND CONTRIBUTORS ``AS IS'' AND
|
21
|
+
# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
22
|
+
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
|
23
|
+
# ARE DISCLAIMED. IN NO EVENT SHALL AUTHOR OR CONTRIBUTORS BE LIABLE
|
24
|
+
# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
25
|
+
# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS
|
26
|
+
# OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
|
27
|
+
# HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
|
28
|
+
# LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY
|
29
|
+
# OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF
|
30
|
+
# SUCH DAMAGE.
|
31
|
+
#
|
32
|
+
################################################################
|
33
|
+
|
34
|
+
|
35
|
+
#
|
36
|
+
# rubygems hack
|
37
|
+
#
|
38
|
+
|
39
|
+
begin
|
40
|
+
require 'rubygems'
|
41
|
+
rescue LoadError => e
|
42
|
+
end
|
43
|
+
begin
|
44
|
+
require 'gamequery'
|
45
|
+
require 'ip'
|
46
|
+
rescue LoadError => e
|
47
|
+
$stderr.puts "gamequerytool requires the gamequery and ip libraries be installed."
|
48
|
+
$stderr.puts "You can find them both via rubygems or at http://rubyforge.org."
|
49
|
+
exit -1
|
50
|
+
end
|
51
|
+
|
52
|
+
GAMEQUERYTOOL_VERSION = "0.1.0"
|
53
|
+
|
54
|
+
require 'optparse'
|
55
|
+
require 'ostruct'
|
56
|
+
require 'rexml/document'
|
57
|
+
require 'rexml/element'
|
58
|
+
require 'rexml/xmldecl'
|
59
|
+
require 'csv'
|
60
|
+
|
61
|
+
#
|
62
|
+
# Option parser
|
63
|
+
#
|
64
|
+
|
65
|
+
def get_options
|
66
|
+
options = OpenStruct.new
|
67
|
+
# our options
|
68
|
+
options.ip_address = nil
|
69
|
+
options.port = nil
|
70
|
+
options.output_type = :standard
|
71
|
+
options.query_type = nil
|
72
|
+
options.server_type = nil
|
73
|
+
options.region = :other
|
74
|
+
options.verbose = false
|
75
|
+
options.filters = []
|
76
|
+
|
77
|
+
optparse = OptionParser.new do |opts|
|
78
|
+
opts.banner = "Usage: #{File.basename $0} <ip_address:port> [options]"
|
79
|
+
opts.separator ""
|
80
|
+
opts.separator "Required Arguments:"
|
81
|
+
|
82
|
+
opts.on("-s", "--server-type [TYPE]", [:hlds, :source],
|
83
|
+
"Type of server to contact:",
|
84
|
+
"\thlds: Half-Life 1 based servers",
|
85
|
+
"\tsource: Half-Life 2 based servers") do |server_type|
|
86
|
+
options.server_type = server_type
|
87
|
+
end
|
88
|
+
|
89
|
+
opts.on("-m", "--master-query", "Run a master server query instead of a normal one") do
|
90
|
+
unless options.query_type.nil?
|
91
|
+
$stderr.puts "Error: Master and Server queries are incompatible."
|
92
|
+
$stderr.puts opts
|
93
|
+
exit -1
|
94
|
+
end
|
95
|
+
|
96
|
+
options.query_type = :master
|
97
|
+
end
|
98
|
+
|
99
|
+
opts.on("-q", "--query-type [TYPE]", [:info, :players, :rules, :full, :ping],
|
100
|
+
"Type of (server-oriented) query (incompatible with -m):",
|
101
|
+
"\tping: get alive status and response time",
|
102
|
+
"\tinfo: server information query",
|
103
|
+
"\tplayers: player information query",
|
104
|
+
"\trules: rules information query",
|
105
|
+
"\tfull: combines info/players/rules") do |query_type|
|
106
|
+
|
107
|
+
unless options.query_type.nil?
|
108
|
+
$stderr.puts "Error: Master and Server queries are incompatible."
|
109
|
+
$stderr.puts opts
|
110
|
+
exit -1
|
111
|
+
end
|
112
|
+
|
113
|
+
options.query_type = query_type
|
114
|
+
end
|
115
|
+
|
116
|
+
opts.separator ""
|
117
|
+
opts.separator "Options:"
|
118
|
+
|
119
|
+
opts.on("-r", "--region [REGION]",
|
120
|
+
[:us_east, :us_west, :south_america,
|
121
|
+
:europe, :asia, :australia, :middle_east,
|
122
|
+
:africa, :other],
|
123
|
+
"Region for master server query:",
|
124
|
+
"\tus_east, us_west, south_america, europe, asia,",
|
125
|
+
"\taustralia, middle_east, africa, or other (the default)") do |region|
|
126
|
+
options.region = region
|
127
|
+
end
|
128
|
+
|
129
|
+
opts.on("-o", "--output-type [TYPE]", [:standard, :xml, :csv],
|
130
|
+
"Type of output to produce (standard, xml, csv)") do |output_type|
|
131
|
+
options.output_type = output_type
|
132
|
+
end
|
133
|
+
|
134
|
+
opts.on("-v", "--[no-]verbose",
|
135
|
+
"Run verbosely.") do |verbose|
|
136
|
+
options.verbose = verbose
|
137
|
+
end
|
138
|
+
|
139
|
+
opts.on("-h", "--help", "This help message.") do
|
140
|
+
$stderr.puts opts
|
141
|
+
exit -1
|
142
|
+
end
|
143
|
+
|
144
|
+
opts.on("--version", "Print the version information.") do
|
145
|
+
$stderr.puts "This is gamequerytool version #{GAMEQUERYTOOL_VERSION},"
|
146
|
+
$stderr.puts "it is located at #{File.expand_path $0}."
|
147
|
+
exit -1
|
148
|
+
end
|
149
|
+
|
150
|
+
opts.separator ""
|
151
|
+
opts.separator "Filters (only work with Master Server Queries):"
|
152
|
+
|
153
|
+
opts.on("--filter-empty", "Remove empty servers from the listing.") do
|
154
|
+
options.filters.push [:empty, true]
|
155
|
+
end
|
156
|
+
|
157
|
+
opts.on("--filter-full", "Remove full servers from the listing.") do
|
158
|
+
options.filters.push [:full, true]
|
159
|
+
end
|
160
|
+
|
161
|
+
opts.on("--filter-insecure", "Remove insecure servers from the listing") do
|
162
|
+
options.filters.push [:secure, true]
|
163
|
+
end
|
164
|
+
|
165
|
+
opts.on("--filter-dedicated-only", "Show dedicated servers only") do
|
166
|
+
options.filters.push [:type, 'd']
|
167
|
+
end
|
168
|
+
|
169
|
+
opts.on("--filter-linux-only", "Show servers running on the Linux OS") do
|
170
|
+
options.filters.push [:linux, true]
|
171
|
+
end
|
172
|
+
|
173
|
+
opts.on("--filter-proxy-only", "Show spectator proxy servers") do |spec|
|
174
|
+
options.filters.push [:proxy, spec]
|
175
|
+
end
|
176
|
+
|
177
|
+
opts.on("--filter-gamedir [GAMEDIR]", "Only show servers running under [GAMEDIR] (mod)") do |gd|
|
178
|
+
options.filters.push [:gamedir, gd]
|
179
|
+
end
|
180
|
+
|
181
|
+
opts.on("--filter-map [MAPNAME]", "Only show servers running [MAPNAME]") do |map|
|
182
|
+
options.filters.push [:map, map]
|
183
|
+
end
|
184
|
+
|
185
|
+
opts.separator ""
|
186
|
+
opts.separator "Note: the default IP:port combination will be used with master queries if one is not provided."
|
187
|
+
opts.separator ""
|
188
|
+
end
|
189
|
+
|
190
|
+
if ARGV[0].nil?
|
191
|
+
$stderr.puts optparse
|
192
|
+
exit -1
|
193
|
+
end
|
194
|
+
|
195
|
+
ip, port = [nil, nil]
|
196
|
+
|
197
|
+
#
|
198
|
+
# validate the IP:port specification if we have one.
|
199
|
+
#
|
200
|
+
|
201
|
+
unless ARGV[0].match(/^-/)
|
202
|
+
error = false
|
203
|
+
ip, port = ARGV.shift.split(":")
|
204
|
+
begin
|
205
|
+
ip = IP::Address.new(ip)
|
206
|
+
tmp = port.to_i
|
207
|
+
error = true if tmp.to_s != port
|
208
|
+
port = tmp
|
209
|
+
rescue IP::AddressException => e
|
210
|
+
error = true
|
211
|
+
end
|
212
|
+
|
213
|
+
if error
|
214
|
+
$stderr.puts "Please use a valid IP:port specification."
|
215
|
+
$stderr.puts optparse
|
216
|
+
exit -1
|
217
|
+
end
|
218
|
+
end
|
219
|
+
|
220
|
+
optparse.parse!
|
221
|
+
|
222
|
+
options.ip_address = ip ? ip.ip_address : nil
|
223
|
+
options.port = port
|
224
|
+
|
225
|
+
if (ip.nil? or port.nil?) and options.query_type != :master
|
226
|
+
$stderr.puts "Server queries require a IP:port specification."
|
227
|
+
$stderr.puts optparse
|
228
|
+
exit -1
|
229
|
+
end
|
230
|
+
|
231
|
+
if [options.query_type, options.server_type].include? nil
|
232
|
+
$stderr.puts "Query type and Server type are required."
|
233
|
+
$stderr.puts optparse
|
234
|
+
exit -1
|
235
|
+
end
|
236
|
+
|
237
|
+
return options
|
238
|
+
|
239
|
+
end
|
240
|
+
|
241
|
+
#
|
242
|
+
# Verbose output
|
243
|
+
#
|
244
|
+
|
245
|
+
def verbose(string)
|
246
|
+
$stderr.puts string if $options.verbose
|
247
|
+
end
|
248
|
+
|
249
|
+
#
|
250
|
+
# Output an assembled XML document.
|
251
|
+
#
|
252
|
+
|
253
|
+
def output_xml(root)
|
254
|
+
verbose("Writing XML")
|
255
|
+
REXML::XMLDecl.new("1.0").write($stdout, 2)
|
256
|
+
$stdout.puts
|
257
|
+
REXML::Document.new.add(root).write($stdout, 2)
|
258
|
+
end
|
259
|
+
|
260
|
+
#
|
261
|
+
# Output master server query information according to format.
|
262
|
+
#
|
263
|
+
|
264
|
+
def output_master_query(queryinfo)
|
265
|
+
case $options.output_type
|
266
|
+
when :standard
|
267
|
+
verbose("Outputting in standard mode")
|
268
|
+
queryinfo.each { |server| puts "#{server.ip}:#{server.port}" }
|
269
|
+
when :xml
|
270
|
+
verbose("Outputting in XML mode")
|
271
|
+
root = REXML::Element.new("gamequery")
|
272
|
+
master = REXML::Element.new("master")
|
273
|
+
server = REXML::Element.new("server")
|
274
|
+
ip = REXML::Element.new("ip")
|
275
|
+
port = REXML::Element.new("port")
|
276
|
+
|
277
|
+
master.add_attribute("type", $options.server_type)
|
278
|
+
root.add_element(master)
|
279
|
+
|
280
|
+
queryinfo.each do |s|
|
281
|
+
s_ip = REXML::Element.new(ip)
|
282
|
+
s_port = REXML::Element.new(port)
|
283
|
+
s_ip.text = s.ip
|
284
|
+
s_port.text = s.port
|
285
|
+
s_server = REXML::Element.new(server)
|
286
|
+
s_server.add_element(s_ip)
|
287
|
+
s_server.add_element(s_port)
|
288
|
+
master.add_element(s_server)
|
289
|
+
end
|
290
|
+
|
291
|
+
output_xml(root)
|
292
|
+
when :csv
|
293
|
+
verbose("Outputting in CSV mode")
|
294
|
+
|
295
|
+
queryinfo.each do |server|
|
296
|
+
puts CSV.generate_line([server.ip, server.port])
|
297
|
+
end
|
298
|
+
end
|
299
|
+
end
|
300
|
+
|
301
|
+
#
|
302
|
+
# Output server query information according to format.
|
303
|
+
#
|
304
|
+
|
305
|
+
def output_server_query(queryinfo)
|
306
|
+
case $options.output_type
|
307
|
+
when :standard
|
308
|
+
verbose("Outputting in standard mode")
|
309
|
+
if queryinfo[:info]
|
310
|
+
verbose("Outputting server information")
|
311
|
+
info = queryinfo[:info]
|
312
|
+
format = "%-20.20s | %37.37s"
|
313
|
+
puts "Server Information:"
|
314
|
+
puts "-" * 60
|
315
|
+
[
|
316
|
+
["Server Name", info.server_name],
|
317
|
+
["Game", info.game_description],
|
318
|
+
["Mod Name (gamedir)", info.gamedir],
|
319
|
+
["Map", info.map],
|
320
|
+
["Players", "#{info.numplayers}/#{info.maxplayers}"],
|
321
|
+
["Bots", info.numbots],
|
322
|
+
["Server OS", info.os],
|
323
|
+
["Dedicated", info.dedicated ? 'yes' : 'no'],
|
324
|
+
["Require Password", info.password ? 'yes' : 'no'],
|
325
|
+
["Using VAC", info.secure ? 'yes' : 'no'],
|
326
|
+
].each { |feh| puts format % feh }
|
327
|
+
puts
|
328
|
+
end
|
329
|
+
if queryinfo[:players]
|
330
|
+
verbose("Outputting player information")
|
331
|
+
format = "%3.3s | %35.35s | %5.5s | %8.8s"
|
332
|
+
puts "Player List: #{queryinfo[:players].num_players} players"
|
333
|
+
puts "%3.3s | %35.35s | %5.5s | %8.8s " % %w(No. Name Kills Time)
|
334
|
+
puts "-" * 60
|
335
|
+
queryinfo[:players].each do |player|
|
336
|
+
seconds = player.time.ceil
|
337
|
+
minutes = seconds / 60
|
338
|
+
hours = minutes / 60
|
339
|
+
|
340
|
+
seconds -= minutes * 60
|
341
|
+
minutes -= hours * 60
|
342
|
+
|
343
|
+
time = "#{"%02d" % (hours > 0 ? hours : 0)}:#{"%02d" % (minutes > 0 ? minutes : 0)}:#{"%02d" % seconds}"
|
344
|
+
|
345
|
+
puts format % [player.index, player.name, player.kills, time]
|
346
|
+
end
|
347
|
+
puts
|
348
|
+
end
|
349
|
+
if queryinfo[:rules]
|
350
|
+
verbose("Outputting rule information")
|
351
|
+
puts "Rules: #{queryinfo[:rules].num_rules}"
|
352
|
+
puts "-" * 60
|
353
|
+
format = "%-25.25s | %32.32s"
|
354
|
+
queryinfo[:rules].rules.each_key { |rule_name| puts format % [rule_name,queryinfo[:rules].rules[rule_name]] }
|
355
|
+
puts
|
356
|
+
end
|
357
|
+
if queryinfo[:ping]
|
358
|
+
verbose("Outputting ping information")
|
359
|
+
ping = queryinfo[:ping] == -1 ? 'Unable to contact' : "#{queryinfo[:ping]}ms"
|
360
|
+
puts "Ping: #{ping}"
|
361
|
+
puts
|
362
|
+
end
|
363
|
+
when :xml
|
364
|
+
verbose("Outputting in XML mode")
|
365
|
+
root = REXML::Element.new("gamequery")
|
366
|
+
server = REXML::Element.new("server")
|
367
|
+
|
368
|
+
server.add_attribute("ip_address", $options.ip_address)
|
369
|
+
server.add_attribute("port", $options.port)
|
370
|
+
|
371
|
+
root.add_element(server)
|
372
|
+
|
373
|
+
if queryinfo[:info]
|
374
|
+
verbose("Outputting server information")
|
375
|
+
info = REXML::Element.new("info")
|
376
|
+
server.add_element(info)
|
377
|
+
|
378
|
+
[:type, :version, :server_name, :map, :gamedir, :game_description,
|
379
|
+
:appid, :numplayers, :maxplayers, :numbots, :dedicated, :os, :password,
|
380
|
+
:secure, :game_version, :host, :modded, :mod_info, :mod_download_ftp,
|
381
|
+
:mod_version, :mod_size, :mod_server_side, :mod_custom_client, :response_type].each do |sym|
|
382
|
+
|
383
|
+
node = REXML::Element.new(sym.to_s)
|
384
|
+
node.text = queryinfo[:info].send(sym).to_s
|
385
|
+
info.add_element(node)
|
386
|
+
end
|
387
|
+
end
|
388
|
+
if queryinfo[:players]
|
389
|
+
verbose("Outputting player information")
|
390
|
+
players = REXML::Element.new("players")
|
391
|
+
player = REXML::Element.new("player")
|
392
|
+
name = REXML::Element.new("name")
|
393
|
+
index = REXML::Element.new("index")
|
394
|
+
time = REXML::Element.new("time")
|
395
|
+
kills = REXML::Element.new("kills")
|
396
|
+
|
397
|
+
server.add_element(players)
|
398
|
+
|
399
|
+
queryinfo[:players].each do |p|
|
400
|
+
p_player = REXML::Element.new(player)
|
401
|
+
p_name = REXML::Element.new(name)
|
402
|
+
p_index = REXML::Element.new(index)
|
403
|
+
p_time = REXML::Element.new(time)
|
404
|
+
p_kills = REXML::Element.new(kills)
|
405
|
+
|
406
|
+
p_name.text = p.name
|
407
|
+
p_index.text = p.index
|
408
|
+
p_time.text = p.time
|
409
|
+
p_kills.text = p.kills
|
410
|
+
|
411
|
+
[p_name, p_index, p_time, p_kills].each { |x| p_player.add_element(x) }
|
412
|
+
players.add_element(p_player)
|
413
|
+
end
|
414
|
+
end
|
415
|
+
if queryinfo[:rules]
|
416
|
+
verbose("Outputting rule information")
|
417
|
+
rules = REXML::Element.new("rules")
|
418
|
+
rule = REXML::Element.new("rule")
|
419
|
+
rules.add_attribute("amount", queryinfo[:rules].num_rules)
|
420
|
+
|
421
|
+
queryinfo[:rules].rules.each_key do |rule_name|
|
422
|
+
new_rule = REXML::Element.new(rule)
|
423
|
+
new_rule.add_attribute("name", rule_name)
|
424
|
+
new_rule.text = queryinfo[:rules].rules[rule_name]
|
425
|
+
rules.add_element(new_rule)
|
426
|
+
end
|
427
|
+
|
428
|
+
server.add_element(rules)
|
429
|
+
end
|
430
|
+
if queryinfo[:ping]
|
431
|
+
verbose("Outputting ping information")
|
432
|
+
ping = REXML::Element.new("ping")
|
433
|
+
ping.text = queryinfo[:ping]
|
434
|
+
server.add_element(ping)
|
435
|
+
end
|
436
|
+
|
437
|
+
output_xml(root)
|
438
|
+
when :csv
|
439
|
+
verbose("Outputting in CSV mode")
|
440
|
+
|
441
|
+
if queryinfo[:info]
|
442
|
+
[:type, :version, :server_name, :map, :gamedir, :game_description,
|
443
|
+
:appid, :numplayers, :maxplayers, :numbots, :dedicated, :os, :password,
|
444
|
+
:secure, :game_version, :host, :modded, :mod_info, :mod_download_ftp,
|
445
|
+
:mod_version, :mod_size, :mod_server_side, :mod_custom_client, :response_type].each do |sym|
|
446
|
+
puts CSV.generate_line(["info", sym, queryinfo[:info].send(sym).to_s.chomp])
|
447
|
+
end
|
448
|
+
end
|
449
|
+
if queryinfo[:players]
|
450
|
+
queryinfo[:players].each do |player|
|
451
|
+
puts CSV.generate_line(["player", player.index, player.name, player.kills, player.time])
|
452
|
+
end
|
453
|
+
end
|
454
|
+
if queryinfo[:rules]
|
455
|
+
queryinfo[:rules].rules.each_key do |rule_name|
|
456
|
+
puts CSV.generate_line(["rule", rule_name, queryinfo[:rules].rules[rule_name]])
|
457
|
+
end
|
458
|
+
end
|
459
|
+
if queryinfo[:ping]
|
460
|
+
puts CSV.generate_line(["ping", queryinfo[:ping]])
|
461
|
+
end
|
462
|
+
end
|
463
|
+
end
|
464
|
+
|
465
|
+
#
|
466
|
+
# Automates server queries, for those types
|
467
|
+
# that actually use multiple queries.
|
468
|
+
#
|
469
|
+
|
470
|
+
def server_query(gq, type)
|
471
|
+
verbose("Initiating '#{type.to_s}' server query to #{$options.ip_address}:#{$options.port}")
|
472
|
+
case type
|
473
|
+
when :info
|
474
|
+
return gq.get_info
|
475
|
+
when :players
|
476
|
+
return gq.get_players
|
477
|
+
when :rules
|
478
|
+
return gq.get_rules
|
479
|
+
when :ping
|
480
|
+
t = (Time.now.to_f * 1000).ceil
|
481
|
+
if gq.ping
|
482
|
+
t2 = (Time.now.to_f * 1000).ceil
|
483
|
+
return t2 - t
|
484
|
+
end
|
485
|
+
return -1
|
486
|
+
end
|
487
|
+
end
|
488
|
+
|
489
|
+
################################################################
|
490
|
+
#
|
491
|
+
# Start main code
|
492
|
+
#
|
493
|
+
################################################################
|
494
|
+
|
495
|
+
$options = get_options
|
496
|
+
|
497
|
+
begin
|
498
|
+
|
499
|
+
case $options.query_type
|
500
|
+
when :master
|
501
|
+
region_map = {
|
502
|
+
:us_east => GameQuery::Master::Steam::REGION_US_EAST,
|
503
|
+
:us_west => GameQuery::Master::Steam::REGION_US_WEST,
|
504
|
+
:south_america => GameQuery::Master::Steam::REGION_SOUTH_AMERICA,
|
505
|
+
:europe => GameQuery::Master::Steam::REGION_EUROPE,
|
506
|
+
:asia => GameQuery::Master::Steam::REGION_ASIA,
|
507
|
+
:australia => GameQuery::Master::Steam::REGION_AUSTRALIA,
|
508
|
+
:middle_east => GameQuery::Master::Steam::REGION_MIDDLE_EAST,
|
509
|
+
:africa => GameQuery::Master::Steam::REGION_AFRICA,
|
510
|
+
:other => GameQuery::Master::Steam::REGION_OTHER
|
511
|
+
}
|
512
|
+
|
513
|
+
if $options.ip_address.nil?
|
514
|
+
$options.ip_address = GameQuery::Master::Steam::MASTER_SERVER
|
515
|
+
|
516
|
+
case $options.server_type
|
517
|
+
when :hlds
|
518
|
+
$options.port = GameQuery::Master::Steam::HLDS_MASTER_PORT
|
519
|
+
when :source
|
520
|
+
$options.port = GameQuery::Master::Steam::SOURCE_MASTER_PORT
|
521
|
+
end
|
522
|
+
end
|
523
|
+
|
524
|
+
verbose("Initiating #{$options.server_type} master query for region '#{$options.region}' to #{$options.ip_address}:#{$options.port}")
|
525
|
+
|
526
|
+
gq = GameQuery::Master::Steam.new($options.ip_address,
|
527
|
+
$options.port,
|
528
|
+
region_map[$options.region])
|
529
|
+
$options.filters.each do |filter|
|
530
|
+
verbose("Setting filter #{filter[0]} = #{filter[1]}")
|
531
|
+
gq.filter.send((filter[0].to_s + '=').to_sym, filter[1])
|
532
|
+
end
|
533
|
+
verbose("Outputting return data")
|
534
|
+
output_master_query(gq.send_query)
|
535
|
+
else
|
536
|
+
gq = GameQuery::Server::Steam.new($options.ip_address, $options.port)
|
537
|
+
case $options.query_type
|
538
|
+
when :full
|
539
|
+
info = server_query(gq, :info)
|
540
|
+
players = server_query(gq, :players)
|
541
|
+
rules = server_query(gq, :rules)
|
542
|
+
ping = server_query(gq, :ping)
|
543
|
+
output_server_query({
|
544
|
+
:info => info,
|
545
|
+
:players => players,
|
546
|
+
:rules => rules,
|
547
|
+
:ping => ping
|
548
|
+
})
|
549
|
+
else
|
550
|
+
output_server_query({ $options.query_type => server_query(gq, $options.query_type) })
|
551
|
+
end
|
552
|
+
end
|
553
|
+
|
554
|
+
rescue GameQuery::ConnectException => e
|
555
|
+
$stderr.puts "Error: A connection error occured (perhaps the host isn't available?)"
|
556
|
+
exit -1
|
557
|
+
rescue GameQuery::ParseException => e
|
558
|
+
$stderr.puts "Error: The tool had trouble processing the response from the server."
|
559
|
+
exit -1
|
560
|
+
rescue Exception => e
|
561
|
+
$stderr.puts "Error: An unknown error occured (#{e.class.to_s})"
|
562
|
+
verbose(e.backtrace.join("\n"))
|
563
|
+
exit -1
|
564
|
+
end
|
565
|
+
|
566
|
+
exit 0
|
data/lib/gamequery.rb
ADDED
@@ -0,0 +1,775 @@
|
|
1
|
+
require 'socket'
|
2
|
+
|
3
|
+
#
|
4
|
+
# GameQuery - Query game servers
|
5
|
+
#
|
6
|
+
# Version:: 0.1.1
|
7
|
+
# Author:: Erik Hollensbe <erik@hollensbe.org>
|
8
|
+
# License:: BSD
|
9
|
+
# Contact:: erik@hollensbe.org
|
10
|
+
# Copyright:: Copyright (c) 2005-2006 Erik Hollensbe
|
11
|
+
#
|
12
|
+
# = Synopsis
|
13
|
+
#
|
14
|
+
# # returning a master server query
|
15
|
+
# gq = GameQuery::Master::Steam.new(
|
16
|
+
# GameQuery::Master::Steam::MASTER_SERVER,
|
17
|
+
# GameQuery::Master::Steam::SOURCE_MASTER_PORT,
|
18
|
+
# GameQuery::Master::Steam::REGION_OTHER)
|
19
|
+
# gq.send_query.each { |server| puts [server.ip, server.port].join(":") }
|
20
|
+
#
|
21
|
+
# # getting a query from a specific server
|
22
|
+
# gq = GameQuery::Server::Steam.new('127.0.0.1', 27015)
|
23
|
+
# gq.get_info.maxplayers => 20
|
24
|
+
#
|
25
|
+
# = Note
|
26
|
+
#
|
27
|
+
# While expansion is planned, current queries are limited
|
28
|
+
# to Valve Software's "Steam"-based games: Half-Life 1 (Goldsrc) and
|
29
|
+
# Half-Life 2 (Source) games in particular. Hunting down the little
|
30
|
+
# differences in several of the protocols not yet supported has
|
31
|
+
# produced troublesome results, especially when it comes to finding
|
32
|
+
# the equivalent of a 'master' server to query to find guinea pigs to
|
33
|
+
# test against. As a result, I will gladly accept patches that help
|
34
|
+
# with other game types.
|
35
|
+
#
|
36
|
+
# = Class Structure
|
37
|
+
#
|
38
|
+
# GameQuery has a similar class structure to my other game-related
|
39
|
+
# module, RCon. There are several important namespaces:
|
40
|
+
#
|
41
|
+
# == GameQuery::Master
|
42
|
+
#
|
43
|
+
# This contains classes that query master server databases. In game
|
44
|
+
# parlance, this means that this is a server you query to get the
|
45
|
+
# names of active, registered servers. The current classes supported
|
46
|
+
# are:
|
47
|
+
#
|
48
|
+
# * GameQuery::Master::Steam - Query Steam master servers
|
49
|
+
# * GameQuery::Master::Steam::Filter - Helper class to build
|
50
|
+
# Steam-based master query filters.
|
51
|
+
#
|
52
|
+
# == GameQuery::Server
|
53
|
+
#
|
54
|
+
# This namespace has classes to get data from individual servers.
|
55
|
+
#
|
56
|
+
# * GameQuery::Server::Steam - Query Steam servers
|
57
|
+
#
|
58
|
+
# == GameQuery::Response
|
59
|
+
#
|
60
|
+
# Contains classes to return well-formed responses, and in the right
|
61
|
+
# cases, provide operations on the response set.
|
62
|
+
#
|
63
|
+
# * GameQuery::Response::Master - Responses from master servers of any
|
64
|
+
# type.
|
65
|
+
# * GameQuery::Response::Steam - Several specific responses that
|
66
|
+
# related to querying steam-based servers.
|
67
|
+
# * GameQuery::Response::Steam::Info - Returns info about the server
|
68
|
+
# itself.
|
69
|
+
# * GameQuery::Response::Steam::Player - Returns info about the
|
70
|
+
# players that are on the server.
|
71
|
+
# * GameQuery::Response::Steam::Rules - Returns info about the rules
|
72
|
+
# that the server has set.
|
73
|
+
#
|
74
|
+
# == Exception Classes
|
75
|
+
#
|
76
|
+
# * GameQuery::ConnectException - Thrown when a connect was attempted
|
77
|
+
# but failed
|
78
|
+
# * GameQuery::ParseException - Thrown when data was improperly
|
79
|
+
# parsed.
|
80
|
+
#
|
81
|
+
# = Further Documentation
|
82
|
+
#
|
83
|
+
# Please see the individual RDoc for each listed class for more
|
84
|
+
# information on usage.
|
85
|
+
#
|
86
|
+
|
87
|
+
class GameQuery
|
88
|
+
|
89
|
+
#
|
90
|
+
# Thrown in the case of a connection attempt that resulted in an
|
91
|
+
# error. Please see individual methods to locate what throws this.
|
92
|
+
#
|
93
|
+
|
94
|
+
class ConnectException < Exception
|
95
|
+
end
|
96
|
+
|
97
|
+
#
|
98
|
+
# Thrown in the case of a connection attempt that resulted in an
|
99
|
+
# error. Please see individual methods to locate what throws this.
|
100
|
+
#
|
101
|
+
|
102
|
+
class ParseException < Exception
|
103
|
+
end
|
104
|
+
|
105
|
+
class Master #:nodoc:
|
106
|
+
|
107
|
+
#
|
108
|
+
# Used for master queries to get lists of steam-based servers
|
109
|
+
#
|
110
|
+
# See GameQuery::Master::Steam.new for more information.
|
111
|
+
#
|
112
|
+
|
113
|
+
class Steam
|
114
|
+
|
115
|
+
# master server IP addresses and ports
|
116
|
+
|
117
|
+
MASTER_SERVER = "207.173.177.11"
|
118
|
+
SOURCE_MASTER_PORT = 27011
|
119
|
+
HLDS_MASTER_PORT = 27010
|
120
|
+
|
121
|
+
# region codes
|
122
|
+
|
123
|
+
REGION_US_EAST = 0x00
|
124
|
+
REGION_US_WEST = 0x01
|
125
|
+
REGION_SOUTH_AMERICA = 0x02
|
126
|
+
REGION_EUROPE = 0x03
|
127
|
+
REGION_ASIA = 0x04
|
128
|
+
REGION_AUSTRALIA = 0x05
|
129
|
+
REGION_MIDDLE_EAST = 0x06
|
130
|
+
REGION_AFRICA = 0x07
|
131
|
+
REGION_OTHER = 0xFF
|
132
|
+
|
133
|
+
#
|
134
|
+
# Creates a new GameQuery::Master::Steam object
|
135
|
+
#
|
136
|
+
# * The region is a 8-bit integer, the accepted values are
|
137
|
+
# constants in this namespace.
|
138
|
+
# * Server is the name of a valid master server. The constant
|
139
|
+
# MASTER_SERVER contains the ip for a current working master
|
140
|
+
# server that serves both HLDS and Source master lists.
|
141
|
+
# * Port is the port to connect to. Note that different lists
|
142
|
+
# are provided on different ports. For this reason, the
|
143
|
+
# constants HLDS_MASTER_PORT and SOURCE_MASTER_PORT are provided
|
144
|
+
# to aid with connecting to the server listed in MASTER_SERVER.
|
145
|
+
#
|
146
|
+
# If you cannot connect to this server, you will need to provide
|
147
|
+
# your own server/port. The region is a part of the protocol,
|
148
|
+
# and most likely won't change.
|
149
|
+
#
|
150
|
+
|
151
|
+
attr_accessor :filter
|
152
|
+
attr_accessor :region
|
153
|
+
|
154
|
+
def initialize(server, port, region)
|
155
|
+
@server = server
|
156
|
+
@port = port
|
157
|
+
@filter = GameQuery::Master::Steam::Filter.new()
|
158
|
+
@region = region
|
159
|
+
# this is what steam sends....
|
160
|
+
@ip = "0.0.0.0:0"
|
161
|
+
self
|
162
|
+
end
|
163
|
+
|
164
|
+
#
|
165
|
+
# Sends the query and returns the output.
|
166
|
+
#
|
167
|
+
# Returns a GameQuery::Response::Master object on success.
|
168
|
+
#
|
169
|
+
# Throws a GameQuery::ConnectException if it can't read
|
170
|
+
# from the server.
|
171
|
+
#
|
172
|
+
|
173
|
+
def send_query
|
174
|
+
retval = []
|
175
|
+
packet = ""
|
176
|
+
|
177
|
+
# send the query
|
178
|
+
sock = UDPSocket.new
|
179
|
+
sock.connect(@server, @port)
|
180
|
+
sock.print [?1, @region, @ip, @filter.generate_filter_data].pack("CCZ*Z*")
|
181
|
+
|
182
|
+
raise GameQuery::ConnectException.new unless IO.select([sock], nil, nil, 10)
|
183
|
+
|
184
|
+
# get the return
|
185
|
+
loop do
|
186
|
+
tmp = sock.recv(8192)
|
187
|
+
packet << tmp
|
188
|
+
break if tmp.length < 8192
|
189
|
+
break unless IO.select([sock], nil, nil, 10)
|
190
|
+
end
|
191
|
+
|
192
|
+
# strip first 6 bytes
|
193
|
+
packet = packet[6..-1]
|
194
|
+
|
195
|
+
while packet.length > 0
|
196
|
+
tmp = packet.slice!(0..5)
|
197
|
+
|
198
|
+
# type switch: String -> Array
|
199
|
+
|
200
|
+
# NOTE: Valve uses network signed here for the port, which
|
201
|
+
# is uncharacteristic compared to other protocols
|
202
|
+
tmp = tmp.unpack("CCCCn")
|
203
|
+
|
204
|
+
port = tmp.pop.to_s
|
205
|
+
retval.push([tmp.join('.'), port])
|
206
|
+
end
|
207
|
+
|
208
|
+
return GameQuery::Response::Master.new(retval)
|
209
|
+
end
|
210
|
+
|
211
|
+
#
|
212
|
+
# Compose filters to send to the steam master servers.
|
213
|
+
#
|
214
|
+
# == Filter names and acceptable data
|
215
|
+
#
|
216
|
+
# Values that take 1/0 can also be set to true/false, the value
|
217
|
+
# being converted at set time.
|
218
|
+
#
|
219
|
+
# type:: this is either 'd' or 'l', for dedicated or listen servers.
|
220
|
+
# secure:: this is 1 or 0 depending on VAC support.
|
221
|
+
# gamedir:: this is the name of the 'game directory' or mod that you want to query specifically for.
|
222
|
+
# map:: name of map.
|
223
|
+
# linux:: set to 1 to only get linux servers back. 0 will return all servers.
|
224
|
+
#--
|
225
|
+
# TODO: really, empty/full should be fixed.
|
226
|
+
#++
|
227
|
+
# empty:: set to 1 to get servers that aren't empty.
|
228
|
+
# full:: set to 1 to get servers that aren't full.
|
229
|
+
# proxy:: set to 1 to get servers that are spectator proxies.
|
230
|
+
#
|
231
|
+
# == Usage
|
232
|
+
#
|
233
|
+
# All methods are accessor-style, meaning you can do this:
|
234
|
+
#
|
235
|
+
# f = GameQuery::Master::Steam::Filter.new
|
236
|
+
# f.empty = 1
|
237
|
+
# f.empty # returns '\empty\1'
|
238
|
+
#
|
239
|
+
# And so on.
|
240
|
+
#
|
241
|
+
|
242
|
+
class Filter
|
243
|
+
|
244
|
+
TYPES = [:type, :secure, :gamedir, :map, :linux, :empty, :full, :proxy]
|
245
|
+
|
246
|
+
TYPES.each do |type|
|
247
|
+
method_name = (type.to_s + '=').to_sym
|
248
|
+
|
249
|
+
GameQuery::Master::Steam::Filter.send(:define_method, type) do
|
250
|
+
x = nil
|
251
|
+
self.instance_eval("x = @{#type.to_s}")
|
252
|
+
x
|
253
|
+
end
|
254
|
+
GameQuery::Master::Steam::Filter.send(:define_method, method_name) do |s|
|
255
|
+
# coerce true/false to numeric equivalents
|
256
|
+
if s == true or s == false
|
257
|
+
s = s ? 1 : 0
|
258
|
+
end
|
259
|
+
|
260
|
+
s = s.to_s
|
261
|
+
|
262
|
+
if s.nil? or s.length == 0
|
263
|
+
self.instance_variable_set(("@"+type_to_s).to_sym, nil)
|
264
|
+
else
|
265
|
+
self.instance_variable_set(("@"+type.to_s).to_sym, "\\#{type.to_s}\\#{s}")
|
266
|
+
end
|
267
|
+
end
|
268
|
+
end
|
269
|
+
|
270
|
+
#
|
271
|
+
# This returns the string containing the filter definitions
|
272
|
+
# formatted for use in a Steam master query.
|
273
|
+
#
|
274
|
+
# It does not include items that are set to nil.
|
275
|
+
#
|
276
|
+
|
277
|
+
def generate_filter_data
|
278
|
+
return [@type, @secure, @gamedir,
|
279
|
+
@map, @linux, @empty,
|
280
|
+
@full, @proxy].reject { |x| x.nil? }.join("")
|
281
|
+
end
|
282
|
+
|
283
|
+
end # Filter
|
284
|
+
end # Steam
|
285
|
+
end # Master
|
286
|
+
|
287
|
+
class Server #:nodoc:
|
288
|
+
|
289
|
+
#
|
290
|
+
# Query Servers that answer to the Steam network protocols.
|
291
|
+
#
|
292
|
+
# Those familiar with the protocol know that a challenge must be
|
293
|
+
# sent: this suite does it, but the challenge protocol is not
|
294
|
+
# exposed (for a variety of reasons). The challenge value is
|
295
|
+
# treated ephemeral and is generated for each query that requires
|
296
|
+
# it.
|
297
|
+
#
|
298
|
+
# The +get_info+ routine does not require a challenge.
|
299
|
+
#
|
300
|
+
# All public methods in this class will return a
|
301
|
+
# GameQuery::ConnectException on connection failure or error.
|
302
|
+
#
|
303
|
+
#--
|
304
|
+
# valve's best docs on this are at:
|
305
|
+
# http://www.valve-erc.com/srcsdk/Code/Networking/serverqueries.html#A2S_INFO
|
306
|
+
#++
|
307
|
+
|
308
|
+
class Steam
|
309
|
+
|
310
|
+
#
|
311
|
+
# Instantiate a new GameQuery::Server::Steam object
|
312
|
+
#
|
313
|
+
# Takes a server and a port.
|
314
|
+
#
|
315
|
+
|
316
|
+
def initialize(server, port)
|
317
|
+
@server = server
|
318
|
+
@port = port
|
319
|
+
@sock = nil
|
320
|
+
end
|
321
|
+
|
322
|
+
#
|
323
|
+
# Sends a query to get all the information from a server
|
324
|
+
# regarding the players that are on it.
|
325
|
+
#
|
326
|
+
# Returns a GameQuery::Response::Steam::Player object.
|
327
|
+
#
|
328
|
+
|
329
|
+
def get_players
|
330
|
+
challenge = get_challenge
|
331
|
+
|
332
|
+
@sock.print [0xFFFFFFFF, 0x55, challenge].pack("VCV")
|
333
|
+
packet = read_socket
|
334
|
+
|
335
|
+
# response header
|
336
|
+
|
337
|
+
response_code, num_players = packet.slice!(0..1).unpack("aC")
|
338
|
+
|
339
|
+
players = []
|
340
|
+
|
341
|
+
while packet.length > 0
|
342
|
+
tmp = packet.unpack("CZ*")
|
343
|
+
|
344
|
+
len = tmp[1].length
|
345
|
+
packet.slice!(0..len+1)
|
346
|
+
|
347
|
+
# we have to do this to ensure that size is proper when decoding little-endian floats. (arg)
|
348
|
+
# this may not work at all.
|
349
|
+
tmp.push(packet.slice!(0..7).unpack("Ve"))
|
350
|
+
|
351
|
+
# BUG: the kills amount (tmp[2]) has an odd anomaly that I haven't tracked down yet.
|
352
|
+
# however, when this bug occurs, the amount filled in the kills portion is 4294967295
|
353
|
+
# I imagine it's a packing issue.
|
354
|
+
|
355
|
+
tmp.flatten!
|
356
|
+
players.push(tmp)
|
357
|
+
end
|
358
|
+
|
359
|
+
return GameQuery::Response::Steam::Player.new(num_players, players)
|
360
|
+
end
|
361
|
+
|
362
|
+
#
|
363
|
+
# Returns the rules for the server as a
|
364
|
+
# GameQuery::Response::Steam::Rules object.
|
365
|
+
#
|
366
|
+
|
367
|
+
def get_rules
|
368
|
+
challenge = get_challenge
|
369
|
+
|
370
|
+
@sock.print [0xFFFFFFFF, 0x56, challenge].pack("VCV")
|
371
|
+
packet = read_socket
|
372
|
+
require 'pp'
|
373
|
+
|
374
|
+
# HL1 responses are different here, as well.
|
375
|
+
# there is some garbage (an extra 9 bytes) that is sent along with
|
376
|
+
# HL1 responses. Since the response code is supposed to be the same,
|
377
|
+
# we can rely on that to find out if we need to remove extra bytes.
|
378
|
+
|
379
|
+
response_code, num_rules = packet.slice!(0..2).unpack("av")
|
380
|
+
|
381
|
+
if response_code != 'E'
|
382
|
+
# if we're here, HL1 will send additional data every packet.
|
383
|
+
# to the best of my knowledge, this is junk.
|
384
|
+
# HACK: THIS IS A HORRIBLE MESS
|
385
|
+
packet.sub!(/\376\377\377\377.{5}/, "")
|
386
|
+
# pull the 6 bytes off the front.
|
387
|
+
packet.slice!(0..5)
|
388
|
+
# do it again
|
389
|
+
response_code, num_rules = packet.slice!(0..2).unpack("av")
|
390
|
+
end
|
391
|
+
|
392
|
+
rules = []
|
393
|
+
|
394
|
+
while packet.length > 0
|
395
|
+
tmp = packet.unpack("Z*Z*")
|
396
|
+
# length of each, + 1 for the nulls
|
397
|
+
packet.slice!(0..(tmp[0].length + tmp[1].length + 1))
|
398
|
+
rules.push(tmp)
|
399
|
+
end
|
400
|
+
|
401
|
+
return GameQuery::Response::Steam::Rules.new(num_rules, rules)
|
402
|
+
end
|
403
|
+
|
404
|
+
#
|
405
|
+
# Gets information about the server itself. Returns a
|
406
|
+
# GameQuery::Response::Steam::Info object.
|
407
|
+
#
|
408
|
+
# Note that while the query sent is the same for both HL2 and
|
409
|
+
# HL1, the responses are quite different, and while much of the
|
410
|
+
# data overlaps, not all of it does. HL1 and HL2 responses will
|
411
|
+
# contains different sets of data as a result. The remaining
|
412
|
+
# fields will be set to 'nil'.
|
413
|
+
#
|
414
|
+
|
415
|
+
def get_info
|
416
|
+
# NOTE: no challenge is needed here
|
417
|
+
establish_connection
|
418
|
+
|
419
|
+
@sock.print [0xFFFFFFFF, ?T, "Source Engine Query"].pack("VCZ*")
|
420
|
+
packet = read_socket
|
421
|
+
#
|
422
|
+
# HL1 and HL2 take the same query, but they provide separate (?!?!) responses
|
423
|
+
# we'll have to parse out the type first, so we know how to unpack the rest of
|
424
|
+
# the output.
|
425
|
+
#
|
426
|
+
type = packet.unpack("a")[0]
|
427
|
+
|
428
|
+
ary = nil
|
429
|
+
if type == 'I'
|
430
|
+
# HL2
|
431
|
+
return GameQuery::Response::Steam::Info.new(packet.unpack("CCZ*Z*Z*Z*vCCCaaCCZ*"),
|
432
|
+
GameQuery::Response::Steam::Info::SOURCE)
|
433
|
+
elsif type == 'm'
|
434
|
+
# HL1
|
435
|
+
return GameQuery::Response::Steam::Info.new(packet.unpack("CZ*Z*Z*Z*Z*CCCaaCCZ*Z*Z*VVCCCC"),
|
436
|
+
GameQuery::Response::Steam::Info::HLDS)
|
437
|
+
end
|
438
|
+
|
439
|
+
# we don't know what it is, annoy our user with an error!
|
440
|
+
raise GameQuery::ParseException.new
|
441
|
+
|
442
|
+
end
|
443
|
+
|
444
|
+
#
|
445
|
+
# Pings the server. Merely returns a true/false depending on if
|
446
|
+
# the server responded.
|
447
|
+
#
|
448
|
+
# While HL1 still supports the legacy ping protocol, HL2 doesn't
|
449
|
+
# (and doesn't appear to have a replacement
|
450
|
+
# implementation). What we do here is send a challenge and
|
451
|
+
# return true/false depending on if we got a response that
|
452
|
+
# makes sense.
|
453
|
+
#
|
454
|
+
# Recording response time is the user's responsibility.
|
455
|
+
#
|
456
|
+
|
457
|
+
def ping
|
458
|
+
return ! get_challenge.nil?
|
459
|
+
end
|
460
|
+
|
461
|
+
################################################################
|
462
|
+
#
|
463
|
+
# BEGIN PRIVATE METHODS
|
464
|
+
#
|
465
|
+
################################################################
|
466
|
+
|
467
|
+
private
|
468
|
+
|
469
|
+
#
|
470
|
+
# Obtain a challenge value
|
471
|
+
#
|
472
|
+
|
473
|
+
def get_challenge
|
474
|
+
establish_connection
|
475
|
+
|
476
|
+
@sock.print [0xFFFFFFFF, 0x57].pack("VC")
|
477
|
+
return read_socket.unpack("aV")[1]
|
478
|
+
end
|
479
|
+
|
480
|
+
#
|
481
|
+
# Create a socket if it's needed
|
482
|
+
#
|
483
|
+
|
484
|
+
def establish_connection
|
485
|
+
if @sock.nil?
|
486
|
+
@sock = UDPSocket.new
|
487
|
+
@sock.connect(@server, @port)
|
488
|
+
end
|
489
|
+
end
|
490
|
+
|
491
|
+
|
492
|
+
#
|
493
|
+
# Read from the socket and return a response. Throws a
|
494
|
+
# GameQuery::ConnectException on IO.select troubles.
|
495
|
+
#
|
496
|
+
|
497
|
+
def read_socket
|
498
|
+
packet = ""
|
499
|
+
|
500
|
+
raise GameQuery::ConnectException.new unless IO.select([@sock], nil, nil, 10)
|
501
|
+
|
502
|
+
# get the return
|
503
|
+
loop do
|
504
|
+
tmp = @sock.recv(8192)
|
505
|
+
packet << tmp
|
506
|
+
break unless IO.select([@sock], nil, nil, 0.1)
|
507
|
+
end
|
508
|
+
|
509
|
+
# this is always the same value, and we never need it.
|
510
|
+
# NOTE: see additional work in get_rules and get_info for HL1
|
511
|
+
packet.slice!(0..3)
|
512
|
+
|
513
|
+
return packet
|
514
|
+
end
|
515
|
+
end
|
516
|
+
end # Server
|
517
|
+
|
518
|
+
class Response #:nodoc:
|
519
|
+
|
520
|
+
#
|
521
|
+
# Master response class.
|
522
|
+
#
|
523
|
+
# All queries which get information from something that resembles
|
524
|
+
# a 'master' server, or a server that provides information
|
525
|
+
# regarding other servers, should return an object of this type.
|
526
|
+
#
|
527
|
+
# This module mixes in the +Enumerable+ module, all methods
|
528
|
+
# contained in there should be available to it.
|
529
|
+
#
|
530
|
+
# It is not recommended that you construct these outside the query
|
531
|
+
# classes - they are merely used to provide a friendly response to
|
532
|
+
# queries.
|
533
|
+
#
|
534
|
+
|
535
|
+
class Master
|
536
|
+
include Enumerable
|
537
|
+
|
538
|
+
GameServer = Struct.new(:ip, :port)
|
539
|
+
|
540
|
+
#
|
541
|
+
# Constructor. Do not use this unless you know what you are doing.
|
542
|
+
#
|
543
|
+
|
544
|
+
def initialize(array)
|
545
|
+
@servers = []
|
546
|
+
|
547
|
+
array.each do |server|
|
548
|
+
@servers.push(GameServer.new(*server))
|
549
|
+
end
|
550
|
+
end
|
551
|
+
|
552
|
+
#
|
553
|
+
# This will return GameQuery::Response::Master::GameServer
|
554
|
+
# structures each time it is called, iterating through the list
|
555
|
+
# of them.
|
556
|
+
#
|
557
|
+
# It's primary function is to aid the +Enumerable+ module.
|
558
|
+
#
|
559
|
+
|
560
|
+
def each
|
561
|
+
@servers.each { |server| yield server }
|
562
|
+
end
|
563
|
+
end
|
564
|
+
|
565
|
+
class Steam #:nodoc:
|
566
|
+
|
567
|
+
#
|
568
|
+
# Source Info class.
|
569
|
+
#
|
570
|
+
# Lots of other protocol information is included in here that
|
571
|
+
# most people will have no use for. I have only covered what I
|
572
|
+
# have deemed "important", however, most of the parts are named
|
573
|
+
# very similarly to the protocol documentation on the web, so if
|
574
|
+
# you're curious what a specific part means, please consult that.
|
575
|
+
#
|
576
|
+
# = Attributes
|
577
|
+
#
|
578
|
+
# Attributes are coerced into boolean values or native types
|
579
|
+
# (such as integers) whenever possible.
|
580
|
+
#
|
581
|
+
# == server_name
|
582
|
+
#
|
583
|
+
# The published name of the server, normally controlled by the
|
584
|
+
# 'hostname' cvar.
|
585
|
+
#
|
586
|
+
# == map
|
587
|
+
#
|
588
|
+
# The name of the map being played.
|
589
|
+
#
|
590
|
+
# == gamedir
|
591
|
+
#
|
592
|
+
# The name of the game directory loaded. This is normally the
|
593
|
+
# 'short name' of the modification.
|
594
|
+
#
|
595
|
+
# == game_description
|
596
|
+
#
|
597
|
+
# The name of the game (or modification) being played. This is
|
598
|
+
# normally the 'long name' of the modification.
|
599
|
+
#
|
600
|
+
# == numplayers
|
601
|
+
#
|
602
|
+
# Number of people currently playing on the server.
|
603
|
+
#
|
604
|
+
# == maxplayers
|
605
|
+
#
|
606
|
+
# Maximum number of people allowed on the server.
|
607
|
+
#
|
608
|
+
# == numbots
|
609
|
+
#
|
610
|
+
# Number of bots on the server.
|
611
|
+
#
|
612
|
+
# == dedicated
|
613
|
+
#
|
614
|
+
# This is set to 'true' if this server is configured to run as a
|
615
|
+
# 'dedicated' (no client running) server.
|
616
|
+
#
|
617
|
+
# == os
|
618
|
+
#
|
619
|
+
# The Operating System the server runs on. 'Windows' or 'Linux'.
|
620
|
+
#
|
621
|
+
# == password
|
622
|
+
#
|
623
|
+
# Set to 'true' if a password is required.
|
624
|
+
#
|
625
|
+
# == secure
|
626
|
+
#
|
627
|
+
# Set to 'true' if running some form of Valve Anti-Cheat.
|
628
|
+
#
|
629
|
+
# == game_version
|
630
|
+
#
|
631
|
+
# Version of the server that is running.
|
632
|
+
#
|
633
|
+
# == host
|
634
|
+
#
|
635
|
+
# IP:port information on the server. This is HL1-only.
|
636
|
+
#
|
637
|
+
# == modded
|
638
|
+
#
|
639
|
+
# 'true' if the server is running a modification. HL1-only.
|
640
|
+
#
|
641
|
+
# == mod_info, mod_download_ftp, mod_version, mod_size, mod_server_side, mod_custom_client
|
642
|
+
#
|
643
|
+
# Various server variables which are HL1-only and will only
|
644
|
+
# exist if 'modded' is true.
|
645
|
+
#
|
646
|
+
|
647
|
+
class Info
|
648
|
+
SOURCE = 0
|
649
|
+
HLDS = 1
|
650
|
+
|
651
|
+
attr_reader(:type, :version, :server_name, :map, :gamedir, :game_description,
|
652
|
+
:appid, :numplayers, :maxplayers, :numbots, :dedicated, :os, :password,
|
653
|
+
:secure, :game_version, :host, :modded, :mod_info, :mod_download_ftp,
|
654
|
+
:mod_version, :mod_size, :mod_server_side, :mod_custom_client, :response_type)
|
655
|
+
|
656
|
+
def initialize(array, response_type=SOURCE)
|
657
|
+
case response_type
|
658
|
+
when SOURCE
|
659
|
+
@response_type = :source
|
660
|
+
(@type, @version, @server_name, @map, @gamedir, @game_description,
|
661
|
+
@appid, @numplayers, @maxplayers, @numbots, @dedicated, @os, @password,
|
662
|
+
@secure, @game_version) = array
|
663
|
+
when HLDS
|
664
|
+
@response_type = :hlds
|
665
|
+
(@type, @host, @server_name, @map, @gamedir, @game_description, @numplayers,
|
666
|
+
@maxplayers, @version, @dedicated, @os, @password, @modded, @mod_info,
|
667
|
+
@mod_download_ftp, dummy, @mod_version, @mod_size, @mod_server_side,
|
668
|
+
@mod_custom_client, @secure, @numbots) = array
|
669
|
+
end
|
670
|
+
|
671
|
+
# booleans to be coerced
|
672
|
+
@dedicated = @dedicated == 'd'
|
673
|
+
@password = @password == 1
|
674
|
+
@secure = @secure == 1
|
675
|
+
@modded = @modded == 1
|
676
|
+
@mod_server_side = @mod_server_side == 1
|
677
|
+
@mod_custom_client = @mod_custom_client == 1
|
678
|
+
|
679
|
+
# OS is Windows or Linux. Coerce.
|
680
|
+
|
681
|
+
@os = @os == 'w' ? 'Windows' : 'Linux'
|
682
|
+
end
|
683
|
+
end
|
684
|
+
|
685
|
+
#
|
686
|
+
# Source Player Info class
|
687
|
+
#
|
688
|
+
# Includes the +Enumerable+ module and all of it's
|
689
|
+
# functions. Allows iterating over a list of player
|
690
|
+
# information. See
|
691
|
+
# GameQuery::Response::Steam::Player::PlayerInfo for the
|
692
|
+
# structure of each player portion.
|
693
|
+
#
|
694
|
+
# Also, the attribute +num_players+ will return the total number
|
695
|
+
# of players currently on the server. This is also available via
|
696
|
+
# an 'info' query, if you just need that information.
|
697
|
+
#
|
698
|
+
|
699
|
+
class Player
|
700
|
+
|
701
|
+
include Enumerable
|
702
|
+
|
703
|
+
#
|
704
|
+
# Player Information struct.
|
705
|
+
#
|
706
|
+
# +index+ is the number of player they are on the server. This
|
707
|
+
# number remains with them until they disconnect.
|
708
|
+
#
|
709
|
+
# +time+ is the time they have been on the server, in floating
|
710
|
+
# point form, in _seconds_.
|
711
|
+
#
|
712
|
+
# Also note that in HL2-based (Source) games, names can be in
|
713
|
+
# UTF-8. I highly recommend using the 'iconv' module to sort
|
714
|
+
# out how you want to best represent that information.
|
715
|
+
#
|
716
|
+
|
717
|
+
PlayerInfo = Struct.new(:index, :name, :kills, :time)
|
718
|
+
|
719
|
+
attr_reader :num_players
|
720
|
+
|
721
|
+
def initialize(num_players, array)
|
722
|
+
@players = []
|
723
|
+
@num_players = num_players
|
724
|
+
|
725
|
+
array.each do |player|
|
726
|
+
pi = PlayerInfo.new(*player)
|
727
|
+
@players.push(pi)
|
728
|
+
end
|
729
|
+
end
|
730
|
+
|
731
|
+
def each
|
732
|
+
@players.each { |player| yield player }
|
733
|
+
end
|
734
|
+
end
|
735
|
+
|
736
|
+
|
737
|
+
#
|
738
|
+
# Source Rules Info Class
|
739
|
+
#
|
740
|
+
# +num_rules+ contains an integer value with the number of rules
|
741
|
+
# in the +rules+ hash
|
742
|
+
#
|
743
|
+
# +rules+ is a hash which contains the rule name as a key and
|
744
|
+
# the value as... the value. Every effort has been made to
|
745
|
+
# convert to proper types when possible.
|
746
|
+
#
|
747
|
+
|
748
|
+
class Rules
|
749
|
+
attr_reader :num_rules
|
750
|
+
attr_reader :rules
|
751
|
+
|
752
|
+
def initialize(num_rules, array)
|
753
|
+
@num_rules = num_rules
|
754
|
+
@rules = Hash.new
|
755
|
+
|
756
|
+
array.each do |rule|
|
757
|
+
# cheap integer conversion
|
758
|
+
tmp = rule[1].to_i
|
759
|
+
if tmp.to_s == rule[1]
|
760
|
+
rule[1] = tmp
|
761
|
+
else
|
762
|
+
# cheap float conversion
|
763
|
+
tmp = rule[1].to_f
|
764
|
+
if tmp.to_s == rule[1]
|
765
|
+
rule[1] = tmp
|
766
|
+
end
|
767
|
+
end
|
768
|
+
|
769
|
+
@rules[rule[0]] = rule[1]
|
770
|
+
end
|
771
|
+
end
|
772
|
+
end # Rules
|
773
|
+
end # Server
|
774
|
+
end # Response
|
775
|
+
end # GameQuery
|
metadata
ADDED
@@ -0,0 +1,54 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
rubygems_version: 0.8.11
|
3
|
+
specification_version: 1
|
4
|
+
name: gamequery
|
5
|
+
version: !ruby/object:Gem::Version
|
6
|
+
version: 0.1.1
|
7
|
+
date: 2006-02-28 00:00:00 -08:00
|
8
|
+
summary: Ruby class to work with Half-Life and Source Engine "user" queries
|
9
|
+
require_paths:
|
10
|
+
- lib
|
11
|
+
email: erik@hollensbe.org
|
12
|
+
homepage:
|
13
|
+
rubyforge_project: gamequery
|
14
|
+
description:
|
15
|
+
autorequire: gamequery
|
16
|
+
default_executable:
|
17
|
+
bindir: bin
|
18
|
+
has_rdoc: true
|
19
|
+
required_ruby_version: !ruby/object:Gem::Version::Requirement
|
20
|
+
requirements:
|
21
|
+
- - ">"
|
22
|
+
- !ruby/object:Gem::Version
|
23
|
+
version: 0.0.0
|
24
|
+
version:
|
25
|
+
platform: ruby
|
26
|
+
signing_key:
|
27
|
+
cert_chain:
|
28
|
+
authors:
|
29
|
+
- Erik Hollensbe
|
30
|
+
files:
|
31
|
+
- lib/gamequery.rb
|
32
|
+
- bin/gamequerytool
|
33
|
+
test_files: []
|
34
|
+
|
35
|
+
rdoc_options: []
|
36
|
+
|
37
|
+
extra_rdoc_files: []
|
38
|
+
|
39
|
+
executables:
|
40
|
+
- gamequerytool
|
41
|
+
extensions: []
|
42
|
+
|
43
|
+
requirements: []
|
44
|
+
|
45
|
+
dependencies:
|
46
|
+
- !ruby/object:Gem::Dependency
|
47
|
+
name: ip
|
48
|
+
version_requirement:
|
49
|
+
version_requirements: !ruby/object:Gem::Version::Requirement
|
50
|
+
requirements:
|
51
|
+
- - ">="
|
52
|
+
- !ruby/object:Gem::Version
|
53
|
+
version: 0.1.1
|
54
|
+
version:
|