gamequery 0.1.1
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.
- 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:
|