ruby-qstat 0.1.1

Sign up to get free protection for your applications and to get access to all the features.
data/.document ADDED
@@ -0,0 +1,5 @@
1
+ README.rdoc
2
+ lib/**/*.rb
3
+ bin/*
4
+ features/**/*.feature
5
+ LICENSE
data/.gitignore ADDED
@@ -0,0 +1,5 @@
1
+ *.sw?
2
+ .DS_Store
3
+ coverage
4
+ rdoc
5
+ pkg
data/LICENSE ADDED
@@ -0,0 +1 @@
1
+ Copyright (c) 2012 kimoto
data/README.rdoc ADDED
@@ -0,0 +1,20 @@
1
+ = ruby-qstat
2
+ QStat Ruby Frontend (Real-time game server stat fetcher)
3
+
4
+ = Require
5
+ - Ruby1.9
6
+ - QStat 2.12
7
+
8
+ = Install
9
+ gem install ruby-qstat
10
+
11
+ = Example
12
+ Update server list from hl2master server
13
+ QStat.query_serverlist("hl2master.steampowered.com:27011", "stm", "left4dead2", maxping = 100)
14
+
15
+ Update server
16
+ QStat.query("xxx.yyy.zzz.qqq", "a2s")
17
+
18
+ Update server (information only)
19
+ QStat.query_serverinfo("xxx.yyy.zzz.qqq", "a2s")
20
+
data/Rakefile ADDED
@@ -0,0 +1,56 @@
1
+ require 'rubygems'
2
+ require 'rake'
3
+
4
+ begin
5
+ require 'jeweler'
6
+ Jeweler::Tasks.new do |gem|
7
+ gem.name = "ruby-qstat"
8
+ gem.summary = %Q{QStat Ruby Frontend (Real-time game server stat fetcher)}
9
+ gem.description = %Q{TODO: longer description of your gem}
10
+ gem.email = "sub+peerler@gmail.com"
11
+ gem.homepage = "http://github.com/kimoto/ruby-qstat"
12
+ gem.authors = ["kimoto"]
13
+ gem.add_development_dependency "thoughtbot-shoulda"
14
+ # gem is a Gem::Specification... see http://www.rubygems.org/read/chapter/20 for additional settings
15
+ end
16
+ rescue LoadError
17
+ puts "Jeweler (or a dependency) not available. Install it with: sudo gem install jeweler"
18
+ end
19
+
20
+ require 'rake/testtask'
21
+ Rake::TestTask.new(:test) do |test|
22
+ test.libs << 'lib' << 'test'
23
+ test.pattern = 'test/**/*_test.rb'
24
+ test.verbose = true
25
+ end
26
+
27
+ begin
28
+ require 'rcov/rcovtask'
29
+ Rcov::RcovTask.new do |test|
30
+ test.libs << 'test'
31
+ test.pattern = 'test/**/*_test.rb'
32
+ test.verbose = true
33
+ end
34
+ rescue LoadError
35
+ task :rcov do
36
+ abort "RCov is not available. In order to run rcov, you must: sudo gem install spicycode-rcov"
37
+ end
38
+ end
39
+
40
+ task :test => :check_dependencies
41
+
42
+ task :default => :test
43
+
44
+ require 'rake/rdoctask'
45
+ Rake::RDocTask.new do |rdoc|
46
+ if File.exist?('VERSION')
47
+ version = File.read('VERSION')
48
+ else
49
+ version = ""
50
+ end
51
+
52
+ rdoc.rdoc_dir = 'rdoc'
53
+ rdoc.title = "ruby-qstat #{version}"
54
+ rdoc.rdoc_files.include('README*')
55
+ rdoc.rdoc_files.include('lib/**/*.rb')
56
+ end
data/VERSION ADDED
@@ -0,0 +1 @@
1
+ 0.1.1
data/lib/ruby-qstat.rb ADDED
@@ -0,0 +1,474 @@
1
+ #-*- coding: utf-8 -*-
2
+ require 'open3'
3
+ require 'nokogiri'
4
+ require 'active_support'
5
+ require 'kconv'
6
+ require 'logger'
7
+
8
+ class String
9
+ def force_convert_to(from, to=nil)
10
+ if to.nil?
11
+ to = from
12
+ end
13
+ self.encode!("UTF-16BE", from, :invalid => :replace, :undef => :replace, :replace => '?')
14
+ self.encode!(to)
15
+ end
16
+ end
17
+
18
+ class QStat
19
+ DEFAULT_MAX_PING = 50
20
+
21
+ @@qstat_path = "qstat"
22
+ @@logger = Logger.new(nil)
23
+
24
+ class Response
25
+ attr_accessor :address
26
+ attr_accessor :xml
27
+ attr_accessor :doc
28
+ attr_accessor :gametype
29
+ attr_accessor :gamename
30
+
31
+ def status_code
32
+ server = @doc.search("/qstat/server[1]").first
33
+ if server.attributes["type"].value.upcase == @gametype.upcase
34
+ if server.attributes["address"].value == @address
35
+ return server.attributes["status"].value
36
+ end
37
+ end
38
+ nil
39
+ end
40
+
41
+ def valid?
42
+ case status_code
43
+ when "UP"
44
+ true
45
+ else
46
+ false
47
+ end
48
+ end
49
+
50
+ def to_s
51
+ @xml
52
+ end
53
+
54
+ def to_ror
55
+ Hash.from_xml(@doc.to_s)
56
+ end
57
+ end
58
+
59
+ class ServerInfo
60
+ attr_accessor :addr
61
+ attr_accessor :num_players
62
+ attr_accessor :map
63
+ attr_accessor :response
64
+ attr_accessor :time
65
+ attr_accessor :server_name
66
+ attr_accessor :players
67
+ attr_accessor :game_type
68
+
69
+ attr_accessor :ping
70
+ attr_accessor :retries
71
+ attr_accessor :number_of_max_players
72
+ attr_accessor :number_of_players
73
+ attr_accessor :status
74
+
75
+ attr_accessor :rules
76
+
77
+ class Rule
78
+ attr_accessor :protocol
79
+ attr_accessor :gamedir
80
+ attr_accessor :gamename
81
+ attr_accessor :bots
82
+ attr_accessor :dedicated
83
+ attr_accessor :sv_os
84
+ attr_accessor :secure
85
+ attr_accessor :version
86
+ attr_accessor :game_port
87
+ attr_accessor :game_tag
88
+
89
+ def initialize
90
+ end
91
+
92
+ def self.create_from_xml(doc)
93
+ rule = Rule.new
94
+ rule.protocol = doc.search("rule[name=protocol]").text
95
+ rule.gamedir = doc.search("rule[name=gamedir]").text
96
+ rule.gamename = doc.search("rule[name=gamename]").text
97
+ rule.bots = doc.search("rule[name=bots]").text
98
+ rule.dedicated = doc.search("rule[name=dedicated]").text
99
+ rule.sv_os = doc.search("rule[name=sv_os]").text
100
+ rule.secure = doc.search("rule[name=secure]").text
101
+ rule.version = doc.search("rule[name=version]").text
102
+ rule.game_port = doc.search("rule[name=game_port]").text
103
+ rule.game_tag = doc.search("rule[name=game_tag]").text
104
+ rule
105
+ end
106
+
107
+ def game_tags
108
+ if @game_tag =~ /^.*\n@(.*)$/
109
+ $1.split(",")
110
+ else
111
+ []
112
+ end
113
+ end
114
+ end
115
+
116
+ def initialize(data=nil)
117
+ if data
118
+ parse_qstat_query_player(data)
119
+ end
120
+ end
121
+
122
+ def self.create_from_xml(doc)
123
+ info = self.new
124
+ info.addr = doc.search("/server/hostname").text
125
+ info.server_name = doc.search("/server/name").text
126
+ info.game_type = doc.search("/server/gametype").text
127
+ info.map = doc.search("/server/map").text
128
+ info.num_players = doc.search("/server/numplayers").text
129
+ info.number_of_max_players = doc.search("/server/maxplayers").text
130
+ info.ping = doc.search("/server/ping").text
131
+ info.retries = doc.search("/server/retries").text
132
+ info.rules = []
133
+ doc.search("/server/rules").each{ |rule|
134
+ info.rules << Rule.create_from_xml(rule)
135
+ }
136
+ info.players = []
137
+ doc.search("/server/players/player").each{ |player|
138
+ info.players << PlayerInfo.create_from_xml(player)
139
+ }
140
+ info.number_of_players = [info.players.size, info.number_of_max_players].join(" / ")
141
+ info.status = doc.search("/server").first.attributes["status"].value
142
+ info
143
+ end
144
+
145
+ def parse_qstat_query_player(data)
146
+ lines = data.split(/\n/)
147
+ @header = lines[0]
148
+ @body = lines[1..-1]
149
+ @players = []
150
+ @rules = []
151
+
152
+ if no_response? or down?
153
+ return nil
154
+ end
155
+
156
+ if @header =~ /^(\d+(?:.\d+){3}:\d+)\s+(\d+\/\d+)\s+(\d+\/\d+)\s+(.*?)\s+([\d\s]+\/\s+\d+)\s+\S+\s+(.*)$/
157
+ @addr = $1
158
+ @num_players = $2
159
+ @map = $4
160
+ @response = $5
161
+ @server_name = $6
162
+
163
+ if @body
164
+ if @body.first =~ /^\t\s*\d+\s*frags.*$/
165
+ @body.each{ |player_info_line|
166
+ @players << PlayerInfo.new(player_info_line)
167
+ }
168
+ else
169
+ server_info = @body.join("\n")
170
+ server_info.force_convert_to('UTF-8')
171
+ server_info.sub!(/^\s+/, "")
172
+ server_info.gsub!(/\u0001/, "")
173
+ hash = {}
174
+ ary = server_info.split(",").map{|e| e.split("=")}
175
+ ary.each{ |e|
176
+ hash[e.first] = e.last
177
+ }
178
+ rule = Rule.new
179
+ rule.protocol = hash["protocol"]
180
+ rule.gamedir = hash["gamedir"]
181
+ rule.gamename = hash["gamename"]
182
+ rule.bots = hash["bots"]
183
+ rule.dedicated = hash["dedicated"]
184
+ rule.sv_os = hash["sv_os"]
185
+ rule.secure = hash["secure"]
186
+ rule.version = hash["version"]
187
+ rule.game_port = hash["game_port"]
188
+ rule.game_tag = hash["game_tag"]
189
+ p rule.game_tags
190
+ @rules << rule
191
+ end
192
+ end
193
+ end
194
+
195
+ @ping = parse_map_info.first
196
+ @number_of_max_players = parse_number_of_players.last
197
+ @number_of_players = [number_of_active_players, @number_of_max_players].join(" / ")
198
+ end
199
+
200
+ def number_of_active_players
201
+ @players.size
202
+ end
203
+
204
+ def no_response?
205
+ @header =~ /^(\d+(?:.\d+){3}:\d+)\s+no response$/
206
+ end
207
+
208
+ def down?
209
+ @header =~ /^(\d+(?:.\d+){3}:\d+)\s+DOWN$/
210
+ end
211
+
212
+ def playing_time
213
+ if empty_server?
214
+ return "00:00"
215
+ end
216
+ longest_playing_player.time_to_s
217
+ end
218
+
219
+ def playing_time_seconds
220
+ if empty_server?
221
+ return 0
222
+ end
223
+ longest_playing_player.time_to_i
224
+ end
225
+
226
+ def longest_playing_player
227
+ @players.max_by{ |player|
228
+ player.time_to_i
229
+ }
230
+ end
231
+
232
+ def empty_server?
233
+ @players.empty?
234
+ end
235
+
236
+ def suggest_game_type
237
+ unless @game_type.nil?
238
+ return @game_type
239
+ end
240
+
241
+ if (not @rules.empty?) and (not @rules.first.game_tags.empty?)
242
+ return @rules.first.game_tags.first
243
+ end
244
+
245
+ return 'unknown'
246
+ end
247
+
248
+ private
249
+ def parse_number_of_players
250
+ @num_players.gsub(" ", "").split("/")
251
+ end
252
+
253
+ def parse_map_info
254
+ @response.gsub(" ", "").split("/")
255
+ end
256
+ end
257
+
258
+ class PlayerInfo
259
+ attr_accessor :name
260
+ attr_accessor :frags
261
+ attr_accessor :time
262
+
263
+ def initialize(player_info_line=nil)
264
+ if player_info_line.nil?
265
+ return self
266
+ end
267
+
268
+ if player_info_line =~ /^\s+(\S+)\s+(\S+)\s+(.*?s)\s+(.*)$/
269
+ @frags = $1
270
+ @time = $3
271
+ @name = $4
272
+ self
273
+ else
274
+ raise "not found playerinfo structure: #{player_info_line}"
275
+ end
276
+ end
277
+
278
+ def self.create_from_xml(xml)
279
+ player_info = self.new
280
+ player_info.name = xml.search("./name").text
281
+ player_info.time = xml.search("./time").text
282
+ player_info.frags = xml.search("./frags").text
283
+ player_info
284
+ end
285
+
286
+ def time_to_i
287
+ qstat_timestr_to_seconds(@time)
288
+ end
289
+
290
+ def time_to_s
291
+ (minutes, seconds) = time_to_i.divmod(60)
292
+ (hours, minutes) = minutes.divmod(60)
293
+ "%02d:%02d" % [hours, minutes]
294
+ end
295
+
296
+ def to_s
297
+ "#<#{@name} #{time_to_s}>"
298
+ end
299
+
300
+ private
301
+ def qstat_timestr_to_seconds(timestr)
302
+ if timestr =~ /^\s*(\d+)h\s*(\d+)m\s*(\d+)s\s*$/
303
+ hours = $1.to_i
304
+ min = $2.to_i
305
+ sec = $3.to_i
306
+ elsif timestr =~ /^\s*(\d+)m\s*(\d+)s\s*$/
307
+ hours = 0
308
+ min = $1.to_i
309
+ sec = $2.to_i
310
+ elsif timestr =~ /^\s*(\d+)s\s*$/
311
+ hours = min = 0
312
+ sec = $1.to_i
313
+ elsif timestr =~ /^\s*(\d+)\s*$/
314
+ hours = min = 0
315
+ sec = $1.to_i
316
+ end
317
+ (hours * 60 * 60) + (min * 60) + sec
318
+ end
319
+ end
320
+
321
+ def initialize
322
+ raise "not implemented"
323
+ end
324
+
325
+ def self.qstat_path=(path)
326
+ @@qstat_path = path
327
+ end
328
+
329
+ def self.logger=(logger)
330
+ @@logger = logger
331
+ end
332
+
333
+ def self.query(host, gametype) # query player info
334
+ self.exec_qstat_query_cmd "#{@@qstat_path} -P -utf8 -nh -#{gametype} #{host}"
335
+ end
336
+ def self.server_info(*args)
337
+ self.query(*args)
338
+ end
339
+
340
+ def self.query_serverinfo(host, gametype) # query server info
341
+ self.exec_qstat_query_cmd "#{@@qstat_path} -R -utf8 -nh -#{gametype} #{host}"
342
+ end
343
+
344
+ def self.query_serverlist(host, gametype, gamename, maxping=DEFAULT_MAX_PING)
345
+ self.qslist(host, gametype, gamename, maxping){ |response|
346
+ if response.valid?
347
+ servers = response.doc.search("/qstat/server").to_a
348
+ if servers.size <= 1
349
+ raise "broken response" # 1件以下はおかしいっす
350
+ end
351
+
352
+ # 最初のserverは構造情報なので除去
353
+ servers.shift
354
+
355
+ # からじゃないよね??
356
+ if servers.empty?
357
+ raise "broken response#2"
358
+ end
359
+
360
+ # 取得したすべてのサーバー情報に対して
361
+ # ServerInfo化していく
362
+ infos = []
363
+ servers.each{ |server|
364
+ info = ServerInfo.create_from_xml(Nokogiri(server.to_s))
365
+ infos << info
366
+ }
367
+ infos
368
+ else
369
+ raise "response is invalid: #{response.inspect}"
370
+ end
371
+ }
372
+ end
373
+
374
+ def self.read_from_xml(path)
375
+ doc = Nokogiri(File.read(path))
376
+ servers = doc.search("/qstat/server").to_a
377
+ if servers.size <= 1
378
+ raise "broken response" # 1件以下はおかしいっす
379
+ end
380
+
381
+ # 最初のserverは構造情報なので除去
382
+ servers.shift
383
+
384
+ # からじゃないよね??
385
+ if servers.empty?
386
+ raise "broken response#2"
387
+ end
388
+
389
+ # 取得したすべてのサーバー情報に対して
390
+ # ServerInfo化していく
391
+ infos = []
392
+ servers.each{ |server|
393
+ info = ServerInfo.create_from_xml(Nokogiri(server.to_s))
394
+ infos << info
395
+ }
396
+ infos
397
+ end
398
+
399
+ def self.qslist(host, gametype, gamename, maxping=DEFAULT_MAX_PING, &block)
400
+ res = Response.new
401
+ res.address = host
402
+ res.gametype = gametype
403
+ res.gamename = gamename
404
+
405
+ broken_xml = self.get_xml(host, gametype, gamename){ |line|
406
+ if line =~ /<ping>(\d+)<\/ping>/
407
+ ping = Regexp.last_match(1).to_i
408
+ if maxping < ping
409
+ true
410
+ end
411
+ end
412
+ }
413
+
414
+ doc = Nokogiri(broken_xml)
415
+ doc.search("/qstat/server/ping").each{ |ping_tag|
416
+ if ping_tag.text.to_i >= maxping
417
+ ping_tag.parent.remove
418
+ end
419
+ }
420
+ res.doc = doc
421
+ res.xml = doc.to_s
422
+
423
+ if block_given?
424
+ block.call(res)
425
+ else
426
+ res
427
+ end
428
+ end
429
+ def self.qstat(*args, &block)
430
+ self.qslist(*args, &block)
431
+ end
432
+
433
+ ## Low API
434
+ def self.exec_qstat_query_cmd(cmd_str)
435
+ ServerInfo.new self.exec_cmd(cmd_str).force_convert_to("UTF-8")
436
+ end
437
+
438
+ def self.exec_cmd(*params, &filter)
439
+ data = ""
440
+ Open3.popen3(*params){ |i,o,e,w|
441
+ i.close_write
442
+
443
+ tl = Thread.new{
444
+ begin
445
+ while line = o.gets
446
+ if block_given? and filter.call(line)
447
+ data += line
448
+ Process.kill :TERM, w.pid
449
+ break
450
+ end
451
+ data += line
452
+ end
453
+ rescue
454
+ @@logger.error $!
455
+ end
456
+ }
457
+ tl2 = Thread.new{
458
+ begin
459
+ while !e.eof?
460
+ @@logger.info e.read 1024
461
+ end
462
+ rescue
463
+ @@logger.error $!
464
+ end
465
+ }
466
+ tl.join
467
+ }
468
+ data
469
+ end
470
+
471
+ def self.get_xml(host, gametype, gamename, &filter)
472
+ self.exec_cmd("#{@@qstat_path} -utf8 -xml -P -R -nh -#{gametype},game=#{gamename} #{host}", &filter)
473
+ end
474
+ end
@@ -0,0 +1,7 @@
1
+ require 'test_helper'
2
+
3
+ class RubyQstatTest < Test::Unit::TestCase
4
+ should "probably rename this file and start testing for real" do
5
+ flunk "hey buddy, you should probably rename this file and start testing for real"
6
+ end
7
+ end
@@ -0,0 +1,10 @@
1
+ require 'rubygems'
2
+ require 'test/unit'
3
+ require 'shoulda'
4
+
5
+ $LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', 'lib'))
6
+ $LOAD_PATH.unshift(File.dirname(__FILE__))
7
+ require 'ruby-qstat'
8
+
9
+ class Test::Unit::TestCase
10
+ end
metadata ADDED
@@ -0,0 +1,112 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: ruby-qstat
3
+ version: !ruby/object:Gem::Version
4
+ prerelease: false
5
+ segments:
6
+ - 0
7
+ - 1
8
+ - 1
9
+ version: 0.1.1
10
+ platform: ruby
11
+ authors:
12
+ - kimoto
13
+ autorequire:
14
+ bindir: bin
15
+ cert_chain: []
16
+
17
+ date: 2012-02-04 00:00:00 +09:00
18
+ default_executable:
19
+ dependencies:
20
+ - !ruby/object:Gem::Dependency
21
+ name: nokogiri
22
+ prerelease: false
23
+ requirement: &id001 !ruby/object:Gem::Requirement
24
+ none: false
25
+ requirements:
26
+ - - ">="
27
+ - !ruby/object:Gem::Version
28
+ segments:
29
+ - 0
30
+ version: "0"
31
+ type: :runtime
32
+ version_requirements: *id001
33
+ - !ruby/object:Gem::Dependency
34
+ name: active_support
35
+ prerelease: false
36
+ requirement: &id002 !ruby/object:Gem::Requirement
37
+ none: false
38
+ requirements:
39
+ - - ">="
40
+ - !ruby/object:Gem::Version
41
+ segments:
42
+ - 0
43
+ version: "0"
44
+ type: :runtime
45
+ version_requirements: *id002
46
+ - !ruby/object:Gem::Dependency
47
+ name: thoughtbot-shoulda
48
+ prerelease: false
49
+ requirement: &id003 !ruby/object:Gem::Requirement
50
+ none: false
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ segments:
55
+ - 0
56
+ version: "0"
57
+ type: :development
58
+ version_requirements: *id003
59
+ description: QStat Ruby Frontend (Real-time game server stat fetcher)
60
+ email: sub+peerler@gmail.com
61
+ executables: []
62
+
63
+ extensions: []
64
+
65
+ extra_rdoc_files:
66
+ - LICENSE
67
+ - README.rdoc
68
+ files:
69
+ - .document
70
+ - .gitignore
71
+ - LICENSE
72
+ - README.rdoc
73
+ - Rakefile
74
+ - VERSION
75
+ - lib/ruby-qstat.rb
76
+ - test/ruby-qstat_test.rb
77
+ - test/test_helper.rb
78
+ has_rdoc: true
79
+ homepage: http://github.com/kimoto/ruby-qstat
80
+ licenses: []
81
+
82
+ post_install_message:
83
+ rdoc_options:
84
+ - --charset=UTF-8
85
+ require_paths:
86
+ - lib
87
+ required_ruby_version: !ruby/object:Gem::Requirement
88
+ none: false
89
+ requirements:
90
+ - - ">="
91
+ - !ruby/object:Gem::Version
92
+ segments:
93
+ - 0
94
+ version: "0"
95
+ required_rubygems_version: !ruby/object:Gem::Requirement
96
+ none: false
97
+ requirements:
98
+ - - ">="
99
+ - !ruby/object:Gem::Version
100
+ segments:
101
+ - 0
102
+ version: "0"
103
+ requirements: []
104
+
105
+ rubyforge_project:
106
+ rubygems_version: 1.3.7
107
+ signing_key:
108
+ specification_version: 3
109
+ summary: QStat Ruby Frontend (Real-time game server stat fetcher)
110
+ test_files:
111
+ - test/test_helper.rb
112
+ - test/ruby-qstat_test.rb