ruby-qstat 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/.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