gamespy_query 0.1.5 → 0.2.0pre

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.
@@ -7,9 +7,8 @@
7
7
  Gamedata values are not split, Player lists can be (names, teams, scores, deaths), while individual values still are not.
8
8
  =end
9
9
 
10
- require_relative 'base'
11
-
12
10
  module GamespyQuery
11
+ # Parsing gamespy query packets and processing them to Hash
13
12
  class Parser < Base
14
13
  STR_SPLIT = STR_X0
15
14
  STR_ID = "\x00\x04\x05\x06\a"
@@ -18,7 +17,11 @@ module GamespyQuery
18
17
  RX_PLAYER_HEADER = /\x01/
19
18
  RX_END = /\x00\x02$/
20
19
 
21
- # packets:
20
+ # Packets to process
21
+ attr_reader :packets
22
+
23
+ # Initializes the object
24
+ # @param [Hash or Array] packets
22
25
  # - Hash, key: packetID, value: packetDATA
23
26
  # or
24
27
  # - Array, packetDATA ordered already by packetID
@@ -33,20 +36,20 @@ module GamespyQuery
33
36
  end
34
37
  end
35
38
 
39
+ # Parse game and player data to hash
36
40
  # Returns Hash with parsed data (:game and :players)
37
- # :game => Hash, Key: InfoKey, Value: InfoValue
38
- # :players => Hash, Key: InfoType, Value: Array of Values
41
+ # game Key: InfoKey, Value: InfoValue
42
+ # players Key: InfoType, Value: Array of Values
39
43
  def parse
40
44
  data = {}
41
45
  data[:game] = {} # Key: InfoKey, Value: InfoValue
42
46
  data[:players] = {} # Key: InfoType, Value: Array of Values
43
47
  player_info = false
44
- player_data = ""
48
+ player_data = "".encode "ASCII-8BIT"
45
49
 
46
50
  # Parse the packets
47
51
  @packets.each do |packet|
48
- packet = clean_packet(packet)
49
-
52
+ packet = clean_packet packet
50
53
  if player_info
51
54
  # Player header was found before, add packet to player_data
52
55
  player_data += packet
@@ -74,6 +77,8 @@ module GamespyQuery
74
77
  data
75
78
  end
76
79
 
80
+ # Clean packet from useless data
81
+ # @param [String] packet Packet data
77
82
  def clean_packet(packet)
78
83
  packet = packet.clone
79
84
  packet.sub!(STR_ID, STR_EMPTY) # Cut off the identity
@@ -82,11 +87,12 @@ module GamespyQuery
82
87
  packet.sub!(RX_X0_S, STR_EMPTY) # Cut off first \x00
83
88
  packet.sub!(RX_END, STR_EMPTY) # Cut off the last \x00\x02
84
89
 
85
- # Encoding
86
- get_string(packet)
90
+ packet
87
91
  end
88
-
89
- def parse_game_data(packet)
92
+
93
+ # Parse game data in packet
94
+ # @param [String] packet Packet to parse
95
+ def parse_game_data(packet)
90
96
  Tools.debug {"Game Parsing #{packet.inspect}"}
91
97
 
92
98
  key = nil
@@ -94,9 +100,9 @@ module GamespyQuery
94
100
 
95
101
  packet.split(STR_SPLIT).each_with_index do |data, index|
96
102
  if (index % 2) == 0
97
- key = clean_string data
103
+ key = encode_string data
98
104
  else
99
- game_data[key] = data.is_a?(String) ? clean_string(data) : data
105
+ game_data[key] = data.is_a?(String) ? encode_string(data) : data
100
106
  end
101
107
  end
102
108
 
@@ -112,11 +118,13 @@ module GamespyQuery
112
118
  STR_SIX = "$SIX_OVERWRITE_PREVIOUS$"
113
119
  STR_SIX_X0 = "\x00#{STR_SIX}\x00"
114
120
 
121
+ # Parse player data in packet
122
+ # @param [String] packet Packet to parse
115
123
  # TODO: Cleanup
116
124
  def parse_player_data(packet)
117
125
  Tools.debug {"Player Parsing #{packet.inspect}"}
118
126
 
119
- player_data = {:names => [], :teams => [], :scores => [], :deaths => []} # [[], [], [], []]
127
+ player_data = {names: [], teams: [], scores: [], deaths: []} # [[], [], [], []]
120
128
 
121
129
  return player_data if packet.nil? || packet.empty?
122
130
 
@@ -154,7 +162,7 @@ module GamespyQuery
154
162
  # Parse the data - \x00 is printed after a non-nil entry, otherwise \x00 means nil (e.g empty team)
155
163
  until str.empty?
156
164
  entry = str[RX_X0_SPEC]
157
- player_data[player_data.keys[i]] << clean_string(entry.sub(STR_X0, STR_EMPTY))
165
+ player_data[player_data.keys[i]] << encode_string(entry.sub(STR_X0, STR_EMPTY))
158
166
  str.sub!(entry, STR_EMPTY)
159
167
  end
160
168
 
@@ -166,6 +174,7 @@ module GamespyQuery
166
174
  overwrite = true # tag so that the next entry will overwrite the latest entry
167
175
  next # ignore
168
176
  else
177
+ info = clean(info) if [2,3].include?(i) # Apply data_type conversion for Score and Deaths
169
178
  if overwrite
170
179
  new_player_data[-1] = info # Overwrite latest entry
171
180
  overwrite = false # done the overwrite
@@ -182,7 +191,8 @@ module GamespyQuery
182
191
  player_data
183
192
  end
184
193
 
185
- # Hash of Hashes
194
+ # Convert player data to Hash of Hashes
195
+ # @param [Hash] data Original player data split over 4 arrays in hash (:names, :teams, :scores, :deaths)
186
196
  def self.pretty_player_data(data)
187
197
  player_data = {}
188
198
 
@@ -196,7 +206,8 @@ module GamespyQuery
196
206
  player_data
197
207
  end
198
208
 
199
- # Array of Hashes
209
+ # Convert player data to Array of Hashes
210
+ # @param [Hash] data Original player data split over 4 arrays in hash (:names, :teams, :scores, :deaths)
200
211
  def self.pretty_player_data2(data)
201
212
  player_data = []
202
213
 
@@ -2,28 +2,31 @@
2
2
  # GameSpy query class by Sickboy [Patrick Roza] (sb_at_dev-heaven.net)
3
3
 
4
4
  require 'yaml'
5
- require_relative 'base'
6
- require_relative 'parser'
7
5
  require 'socket'
8
6
 
9
7
  module GamespyQuery
8
+ # Provides socket functionality on multiple platforms
10
9
  # TODO
11
10
  module MultiSocket
11
+ # Create socket
12
12
  def create_socket(*params)
13
13
  Tools.debug {"Creating socket #{params}"}
14
14
  _create_socket(*params)
15
15
  end
16
16
 
17
+ # Write socket
17
18
  def socket_send(*params)
18
19
  Tools.debug {"Sending socket #{params}"}
19
20
  _socket_send(*params)
20
21
  end
21
22
 
23
+ # Read socket
22
24
  def socket_receive(*params)
23
25
  Tools.debug {"Receiving socket #{params}"}
24
26
  _socket_receive(*params)
25
27
  end
26
28
 
29
+ # Close socket
27
30
  def socket_close(*params)
28
31
  Tools.debug {"Closing socket #{params}"}
29
32
  @s.close
@@ -33,6 +36,7 @@ module GamespyQuery
33
36
  include System::Net
34
37
  include System::Net::Sockets
35
38
 
39
+ # Create socket
36
40
  def _create_socket(host, port)
37
41
  @ip_end_point = IPEndPoint.new(IPAddress.Any, 0)
38
42
  @s = UdpClient.new
@@ -40,25 +44,30 @@ module GamespyQuery
40
44
  @s.connect(host, port.to_i)
41
45
  end
42
46
 
47
+ # Write socket
43
48
  def _socket_send(packet)
44
49
  @s.Send(packet, packet.length)
45
50
  end
46
51
 
52
+ # Read socket
47
53
  def _socket_receive
48
54
  @s.Receive(@ip_end_point)
49
55
  end
50
56
 
51
57
  else
52
58
 
59
+ # Create socket
53
60
  def _create_socket(host, port)
54
61
  @s = UDPSocket.new
55
62
  @s.connect(host, port)
56
63
  end
57
64
 
65
+ # Write socket
58
66
  def _socket_send(packet)
59
67
  @s.puts(packet)
60
68
  end
61
69
 
70
+ # Read socket
62
71
  def _socket_receive
63
72
  begin
64
73
  Timeout::timeout(DEFAULT_TIMEOUT) do
@@ -73,12 +82,20 @@ module GamespyQuery
73
82
  end
74
83
  end
75
84
 
85
+ # Provides direct connection functionality to gamespy enabled game servers
86
+ # This query contains up to 7x more information than the gamespy master browser query
87
+ # For example, player lists with info (teams, scores, deaths) are only available by using direct connection
76
88
  class Socket < UDPSocket
77
89
  include Funcs
78
90
 
91
+ # Default timeout per connection state
79
92
  DEFAULT_TIMEOUT = 3
93
+
94
+ # Maximum amount of packets sent by the server
95
+ # This is a limit set by gamespy
80
96
  MAX_PACKETS = 7
81
97
 
98
+ # Packet bits
82
99
  ID_PACKET = [0x04, 0x05, 0x06, 0x07].pack("c*") # TODO: Randomize?
83
100
  BASE_PACKET = [0xFE, 0xFD, 0x00].pack("c*")
84
101
  CHALLENGE_PACKET = [0xFE, 0xFD, 0x09].pack("c*")
@@ -88,6 +105,7 @@ module GamespyQuery
88
105
  SERVER_INFO_PACKET = [0xFF, 0x00, 0x00].pack("c*")
89
106
  PLAYER_INFO_PACKET = [0x00, 0xFF, 0x00].pack("c*")
90
107
 
108
+ # Maximum receive size
91
109
  RECEIVE_SIZE = 1500
92
110
 
93
111
  STR_HOSTNAME = "hostname"
@@ -117,6 +135,9 @@ module GamespyQuery
117
135
 
118
136
  attr_accessor :addr, :data, :state, :stamp, :needs_challenge, :max_packets, :failed
119
137
 
138
+ # Initializes the object
139
+ # @param [String] addr Server address ("ip:port")
140
+ # @param [Address Family] address_family
120
141
  def initialize(addr, address_family = ::Socket::AF_INET)
121
142
  @addr, @data, @state, @max_packets = addr, {}, 0, MAX_PACKETS
122
143
  @id_packet = ID_PACKET
@@ -126,10 +147,22 @@ module GamespyQuery
126
147
  self.connect(*addr.split(":"))
127
148
  end
128
149
 
150
+ # Exception
151
+ class NotInWriteState < StandardError
152
+ end
153
+
154
+ # Exception
155
+ class NotInReadState < StandardError
156
+ end
157
+
158
+ # Sets the state of the socket
159
+ # @param [Integer] state State to set
129
160
  def state=(state); @stamp = Time.now; @state = state; end
130
161
 
162
+ # Is the socket state valid? Only if all states have passed
131
163
  def valid?; @state == STATE_READY; end
132
164
 
165
+ # Handle the write state
133
166
  def handle_write
134
167
  #Tools.debug {"Write: #{self.inspect}, #{self.state}"}
135
168
 
@@ -146,11 +179,17 @@ module GamespyQuery
146
179
  # Send Challenge response
147
180
  self.puts self.needs_challenge ? BASE_PACKET + @id_packet + self.needs_challenge + FULL_INFO_PACKET_MP : BASE_PACKET + @id_packet + FULL_INFO_PACKET_MP
148
181
  self.state = STATE_SENT_CHALLENGE_RESPONSE
182
+ else
183
+ raise NotInWriteState, "NotInWriteState"
149
184
  end
185
+ rescue NotInWriteState => e
186
+ r = false
187
+ self.failed = true
188
+ close unless closed?
150
189
  rescue => e
151
190
  Tools.log_exception e
152
191
  self.failed = true
153
- r = false
192
+ r = nil
154
193
  close unless closed?
155
194
  end
156
195
 
@@ -165,32 +204,26 @@ module GamespyQuery
165
204
  r
166
205
  end
167
206
 
207
+ # Handle the read state
168
208
  def handle_read
169
209
  # Tools.debug {"Read: #{self.inspect}, #{self.state}"}
170
210
 
171
211
  r = true
172
- case self.state
173
- when STATE_SENT_CHALLENGE
174
- begin
212
+ begin
213
+ case self.state
214
+ when STATE_SENT_CHALLENGE
175
215
  data = self.recvfrom_nonblock(RECEIVE_SIZE)
176
216
  Tools.debug {"Read (1): #{self.inspect}: #{data}"}
177
217
 
178
- handle_challenge get_string(data[0])
218
+ handle_challenge data[0]
179
219
 
180
220
  self.state = STATE_RECEIVED_CHALLENGE
181
- rescue => e
182
- Tools.log_exception e
183
- self.failed = true
184
- r = false
185
- close unless closed?
186
- end
187
- when STATE_SENT_CHALLENGE_RESPONSE, STATE_RECEIVE_DATA
188
- begin
221
+ when STATE_SENT_CHALLENGE_RESPONSE, STATE_RECEIVE_DATA
189
222
  data = self.recvfrom_nonblock(RECEIVE_SIZE)
190
223
  Tools.debug {"Read (3,4): #{self.inspect}: #{data}"}
191
224
  self.state = STATE_RECEIVE_DATA
192
225
 
193
- game_data = get_string(data[0])
226
+ game_data = data[0]
194
227
  Tools.debug {"Received (#{self.data.size + 1}):\n\n#{game_data.inspect}\n\n#{game_data}\n\n"}
195
228
 
196
229
  index = handle_splitnum game_data
@@ -203,16 +236,25 @@ module GamespyQuery
203
236
  r = false
204
237
  close unless closed?
205
238
  end
206
- rescue => e
207
- Tools.log_exception(e)
208
- self.failed = true
209
- r = false
210
- close unless closed?
211
- end
239
+ else
240
+ raise NotInReadState, "NotInReadState"
241
+ end
242
+ rescue NotInReadState => e
243
+ r = false
244
+ self.failed = true
245
+ close unless closed?
246
+ rescue => e
247
+ # TODO: Simply raise the exception?
248
+ Tools.log_exception(e)
249
+ self.failed = true
250
+ r = nil
251
+ close unless closed?
212
252
  end
213
253
  r
214
254
  end
215
255
 
256
+ # Handle the exception state
257
+ # TODO
216
258
  def handle_exc
217
259
  Tools.debug {"Exception: #{self.inspect}"}
218
260
  close unless closed?
@@ -221,9 +263,11 @@ module GamespyQuery
221
263
  false
222
264
  end
223
265
 
224
- def handle_splitnum game_data
266
+ # Process the splitnum provided in the packet
267
+ # @param [String] packet Packet data
268
+ def handle_splitnum packet
225
269
  index = 0
226
- if game_data.sub(STR_GARBAGE, STR_EMPTY)[RX_SPLITNUM]
270
+ if packet.sub(STR_GARBAGE, STR_EMPTY)[RX_SPLITNUM]
227
271
  splitnum = $1
228
272
  flag = splitnum.unpack("C")[0]
229
273
  index = (flag & 127).to_i
@@ -238,19 +282,24 @@ module GamespyQuery
238
282
  index
239
283
  end
240
284
 
241
- def handle_challenge str
242
- # Tools.debug{"Received challenge response (#{str.length}): #{str.inspect}"}
243
- need_challenge = !(str.sub(STR_X0, STR_EMPTY) =~ RX_NO_CHALLENGE)
285
+ # Handle the challenge/response, if the server requires it
286
+ # @param [String] packet Packet to process for challenge/response
287
+ def handle_challenge packet
288
+ # Tools.debug{"Received challenge response (#{packet.length}): #{packet.inspect}"}
289
+ need_challenge = !(packet.sub(STR_X0, STR_EMPTY) =~ RX_NO_CHALLENGE)
244
290
  if need_challenge
245
- str = str.sub(RX_CHALLENGE, STR_EMPTY).gsub(RX_CHALLENGE2, STR_EMPTY).to_i
291
+ str = packet.sub(RX_CHALLENGE, STR_EMPTY).gsub(RX_CHALLENGE2, STR_EMPTY).to_i
246
292
  challenge_packet = sprintf(STR_BLA, handle_chr(str >> 24), handle_chr(str >> 16), handle_chr(str >> 8), handle_chr(str >> 0))
247
293
  self.needs_challenge = challenge_packet
248
294
  end
249
295
  end
250
296
 
297
+ # Determine Read/Write/Exception state
251
298
  def handle_state; [STATE_INIT, STATE_RECEIVED_CHALLENGE].include? state; end
252
299
 
300
+ # Process data
253
301
  # Supports challenge/response and multi-packet
302
+ # @param [String] reply Reply from server
254
303
  def sync reply = self.fetch
255
304
  game_data, key = {}, nil
256
305
  return game_data if reply.nil? || reply.empty?
@@ -266,6 +315,7 @@ module GamespyQuery
266
315
  game_data
267
316
  end
268
317
 
318
+ # Fetch all packets from socket
269
319
  def fetch
270
320
  pings = []
271
321
  r = self.data
@@ -275,13 +325,13 @@ module GamespyQuery
275
325
  if IO.select(nil, [self], nil, DEFAULT_TIMEOUT)
276
326
  handle_write
277
327
  else
278
- raise "TimeOut"
328
+ raise TimeOutError, "TimeOut"
279
329
  end
280
330
  else
281
331
  if IO.select([self], nil, nil, DEFAULT_TIMEOUT)
282
332
  handle_read
283
333
  else
284
- raise "TimeOut"
334
+ raise TimeOutError, "TimeOut"
285
335
  end
286
336
  end
287
337
  end
@@ -295,6 +345,7 @@ module GamespyQuery
295
345
  Tools.debug{"Gamespy pings: #{pings}, #{ping}"}
296
346
  @ping = ping
297
347
  rescue => e
348
+ # TODO: Simply raise the exception?
298
349
  Tools.log_exception(e)
299
350
  r = nil
300
351
  close unless closed?
@@ -303,18 +354,3 @@ module GamespyQuery
303
354
  end
304
355
  end
305
356
  end
306
-
307
- if $0 == __FILE__
308
- host, port = if ARGV.size > 1
309
- ARGV
310
- else
311
- ARGV[0].split(":")
312
- end
313
- time_start = Time.now
314
- g = GamespyQuery::Socket.new("#{host}:#{port}")
315
- r = g.sync
316
- time_taken = Time.now - time_start
317
- puts "Took: #{time_taken}s"
318
- exit unless r
319
- puts r.to_yaml
320
- end
@@ -1,18 +1,28 @@
1
- require_relative 'socket'
2
-
3
1
  module GamespyQuery
2
+ # Provides mass processing of Gamespy UDP sockets, by using Socket/IO select
4
3
  class SocketMaster < Base
4
+ # Should the current queue be extended to the maximum amount of connections, or should the queue be emptied first,
5
+ # before adding more?
5
6
  FILL_UP_ON_SPACE = true
7
+
8
+ # Default maximum concurrent connections
6
9
  DEFAULT_MAX_CONNECTIONS = 128
7
10
 
8
- attr_accessor :timeout, :max_connections
11
+ # Configurable timeout in seconds
12
+ attr_accessor :timeout
13
+
14
+ # Configurable max concurrenct connections
15
+ attr_accessor :max_connections
9
16
 
17
+ # Initializes the object
18
+ # @param [Array] addrs List of addresses to process
10
19
  def initialize addrs
11
20
  @addrs = addrs
12
21
 
13
22
  @timeout, @max_connections = Socket::DEFAULT_TIMEOUT, DEFAULT_MAX_CONNECTIONS # Per select iteration
14
23
  end
15
24
 
25
+ # Process the list of addresses
16
26
  def process!
17
27
  sockets = []
18
28
 
@@ -56,6 +66,42 @@ module GamespyQuery
56
66
 
57
67
  return sockets
58
68
  end
69
+
70
+ class <<self
71
+ # Fetch the gamespy master browser list
72
+ # Connect to each individual server to receive player data etc
73
+ # @param [String] game Game to fetch info from
74
+ # @param [String] geo Geo location enabled?
75
+ # @param [Array] remote Hostname and path+filename strings if the list needs to be fetched from http server
76
+ def process_master(game = "arma2oapc", geo = nil, remote = nil)
77
+ master = GamespyQuery::Master.new(geo, game)
78
+ list = if remote
79
+ Net::HTTP.start(remote[0]) do |http|
80
+ resp = http.get(remote[1])
81
+ resp.body.split("\n")
82
+ end
83
+ else
84
+ master.get_server_list(nil, true, geo)
85
+ end
86
+
87
+ dat = master.process list
88
+
89
+ sm = GamespyQuery::SocketMaster.new(dat.keys)
90
+ sockets = sm.process!
91
+ sockets.select{|s| s.valid? }.each do |s|
92
+ begin
93
+ data = dat[s.addr]
94
+ data[:ip], data[:port] = s.addr.split(":")
95
+ data[:gamename] = game
96
+ data[:gamedata].merge!(s.sync(s.data))
97
+ rescue => e
98
+ Tools.log_exception e
99
+ end
100
+ end
101
+
102
+ dat.values
103
+ end
104
+ end
59
105
  end
60
106
  end
61
107