Completed
Push — v3 ( 065f93...781e5e )
by Austin
10:22 queued 06:22
created

GameQ::doParseResponse()   B

Complexity

Conditions 6
Paths 22

Size

Total Lines 47
Code Lines 23

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 19
CRAP Score 6

Importance

Changes 1
Bugs 0 Features 0
Metric Value
c 1
b 0
f 0
dl 0
loc 47
ccs 19
cts 19
cp 1
rs 8.5125
cc 6
eloc 23
nc 22
nop 1
crap 6
1
<?php
2
/**
3
 * This file is part of GameQ.
4
 *
5
 * GameQ is free software; you can redistribute it and/or modify
6
 * it under the terms of the GNU Lesser General Public License as published by
7
 * the Free Software Foundation; either version 3 of the License, or
8
 * (at your option) any later version.
9
 *
10
 * GameQ is distributed in the hope that it will be useful,
11
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
12
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
13
 * GNU Lesser General Public License for more details.
14
 *
15
 * You should have received a copy of the GNU Lesser General Public License
16
 * along with this program.  If not, see <http://www.gnu.org/licenses/>.
17
 */
18
namespace GameQ;
19
20
use GameQ\Exception\Protocol as ProtocolException;
21
22
/**
23
 * Base GameQ Class
24
 *
25
 * This class should be the only one that is included when you use GameQ to query
26
 * any games servers.
27
 *
28
 * Requirements: See wiki or README for more information on the requirements
29
 *  - PHP 5.4.14+
30
 *    * Bzip2 - http://www.php.net/manual/en/book.bzip2.php
31
 *
32
 * @author Austin Bischoff <[email protected]>
33
 *
34
 * @property bool   $debug
35
 * @property string $capture_packets_file
36
 * @property int    $stream_timeout
37
 * @property int    $timeout
38
 * @property int    $write_wait
39
 */
40
class GameQ
41
{
42
    /*
43
     * Constants
44
     */
45
46
    /**
47
     * Current version
48
     */
49
    const VERSION = '3.0.0-alpha2';
50
51
    /* Static Section */
52
53
    /**
54
     * Holds the instance of itself
55
     *
56
     * @type self
57
     */
58
    protected static $instance = null;
59
60
    /**
61
     * Create a new instance of this class
62
     *
63
     * @return \GameQ\GameQ
64
     */
65 1
    public static function factory()
66
    {
67
68
        // Create a new instance
69 1
        self::$instance = new self();
70
71
        // Return this new instance
72 1
        return self::$instance;
73
    }
74
75
    /* Dynamic Section */
76
77
    /**
78
     * Default options
79
     *
80
     * @type array
81
     */
82
    protected $options = [
83
        'debug'                => false,
84
        'timeout'              => 3, // Seconds
85
        'filters'              => [ 'normalize' => [ ] ],
86
        // Advanced settings
87
        'stream_timeout'       => 200000, // See http://www.php.net/manual/en/function.stream-select.php for more info
88
        'write_wait'           => 500,
89
        // How long (in micro-seconds) to pause between writing to server sockets, helps cpu usage
90
91
        // Used for generating protocol test data
92
        'capture_packets_file' => null,
93
    ];
94
95
    /**
96
     * Array of servers being queried
97
     *
98
     * @type array
99
     */
100
    protected $servers = [ ];
101
102
    /**
103
     * The query library to use.  Default is Native
104
     *
105
     * @type string
106
     */
107
    protected $queryLibrary = 'GameQ\\Query\\Native';
108
109
    /**
110
     * Holds the instance of the queryLibrary
111
     *
112
     * @type \GameQ\Query\Core|null
113
     */
114
    protected $query = null;
115
116
    /**
117
     * Get an option's value
118
     *
119
     * @param mixed $option
120
     *
121
     * @return mixed|null
122
     */
123 119
    public function __get($option)
124
    {
125
126 119
        return isset($this->options[$option]) ? $this->options[$option] : null;
127
    }
128
129
    /**
130
     * Set an option's value
131
     *
132
     * @param mixed $option
133
     * @param mixed $value
134
     *
135
     * @return bool
136
     */
137 121
    public function __set($option, $value)
138
    {
139
140 121
        $this->options[$option] = $value;
141
142 121
        return true;
143
    }
144
145
    /**
146
     * Chainable call to __set, uses set as the actual setter
147
     *
148
     * @param mixed $var
149
     * @param mixed $value
150
     *
151
     * @return $this
152
     */
153 121
    public function setOption($var, $value)
154
    {
155
156
        // Use magic
157 121
        $this->{$var} = $value;
158
159 121
        return $this; // Make chainable
160
    }
161
162
    /**
163
     * Add a single server
164
     *
165
     * @param array $server_info
166
     *
167
     * @return $this
168
     */
169 2
    public function addServer(array $server_info = [ ])
170
    {
171
172
        // Add and validate the server
173 2
        $this->servers[uniqid()] = new Server($server_info);
174
175 2
        return $this; // Make calls chainable
176
    }
177
178
    /**
179
     * Add multiple servers in a single call
180
     *
181
     * @param array $servers
182
     *
183
     * @return $this
184
     */
185 2
    public function addServers(array $servers = [ ])
186
    {
187
188
        // Loop through all the servers and add them
189 2
        foreach ($servers as $server_info) {
190 2
            $this->addServer($server_info);
191 2
        }
192
193 2
        return $this; // Make calls chainable
194
    }
195
196
    /**
197
     * Add a set of servers from a file or an array of files.
198
     * Supported formats:
199
     * JSON
200
     *
201
     * @param array $files
202
     *
203
     * @return $this
204
     * @throws \Exception
205
     */
206 1
    public function addServersFromFiles($files = [ ])
207
    {
208
209
        // Since we expect an array let us turn a string (i.e. single file) into an array
210 1
        if (!is_array($files)) {
211 1
            $files = [ $files ];
212 1
        }
213
214
        // Iterate over the file(s) and add them
215 1
        foreach ($files as $file) {
216
            // Check to make sure the file exists and we can read it
217 1
            if (!file_exists($file) || !is_readable($file)) {
218 1
                continue;
219
            }
220
221
            // See if this file is JSON
222 1
            if (($servers = json_decode(file_get_contents($file), true)) === null
223 1
                && json_last_error() !== JSON_ERROR_NONE
224 1
            ) {
225
                // Type not supported
226 1
                continue;
227
            }
228
229
            // Add this list of servers
230 1
            $this->addServers($servers);
231 1
        }
232
233 1
        return $this;
234
    }
235
236
    /**
237
     * Clear all of the defined servers
238
     *
239
     * @return $this
240
     */
241 2
    public function clearServers()
242
    {
243
244
        // Reset all the servers
245 2
        $this->servers = [ ];
246
247 2
        return $this; // Make Chainable
248
    }
249
250
    /**
251
     * Add a filter to the processing list
252
     *
253
     * @param string $filterName
254
     * @param array  $options
255
     *
256
     * @return $this
257
     */
258 3
    public function addFilter($filterName, $options = [ ])
259
    {
260
261
        // Add the filter
262 3
        $this->options['filters'][strtolower($filterName)] = $options;
263
264 3
        return $this;
265
    }
266
267
    /**
268
     * Remove a filter from processing
269
     *
270
     * @param string $filterName
271
     *
272
     * @return $this
273
     */
274 121
    public function removeFilter($filterName)
275
    {
276
277
        // Set to all lower
278 121
        $filterName = strtolower($filterName);
279
280
        // Remove this filter if it has been defined
281 121
        if (array_key_exists($filterName, $this->options['filters'])) {
282 121
            unset($this->options['filters'][$filterName]);
283 121
        }
284
285 121
        return $this;
286
    }
287
288
    /**
289
     * Main method used to actually process all of the added servers and return the information
290
     *
291
     * @return array
292
     * @throws \Exception
293
     */
294
    public function process()
295
    {
296
297
        // Initialize the query library we are using
298
        $class = new \ReflectionClass($this->queryLibrary);
299
300
        // Set the query pointer to the new instance of the library
301
        $this->query = $class->newInstance();
302
303
        unset($class);
304
305
        // Define the return
306
        $results = [ ];
307
308
        // @todo: Add break up into loop to split large arrays into smaller chunks
309
310
        // Do server challenge(s) first, if any
311
        $this->doChallenges();
312
313
        // Do packets for server(s) and get query responses
314
        $this->doQueries();
315
316
        // Now we should have some information to process for each server
317
        foreach ($this->servers as $server) {
318
            /* @var $server \GameQ\Server */
319
320
            // Parse the responses for this server
321
            $result = $this->doParseResponse($server);
322
323
            // Apply the filters
324
            $result = array_merge($result, $this->doApplyFilters($result, $server));
325
326
            // Sort the keys so they are alphabetical and nicer to look at
327
            ksort($result);
328
329
            // Add the result to the results array
330
            $results[$server->id()] = $result;
331
        }
332
333
        return $results;
334
    }
335
336
    /**
337
     * Do server challenges, where required
338
     */
339
    protected function doChallenges()
340
    {
341
342
        // Initialize the sockets for reading
343
        $sockets = [ ];
344
345
        // By default we don't have any challenges to process
346
        $server_challenge = false;
347
348
        // Do challenge packets
349
        foreach ($this->servers as $server_id => $server) {
350
            /* @var $server \GameQ\Server */
351
352
            // This protocol has a challenge packet that needs to be sent
353
            if ($server->protocol()->hasChallenge()) {
354
                // We have a challenge, set the flag
355
                $server_challenge = true;
356
357
                // Let's make a clone of the query class
358
                $socket = clone $this->query;
359
360
                // Set the information for this query socket
361
                $socket->set(
362
                    $server->protocol()->transport(),
363
                    $server->ip,
364
                    $server->port_query,
365
                    $this->timeout
366
                );
367
368
                // Now write the challenge packet to the socket.
369
                $socket->write($server->protocol()->getPacket(Protocol::PACKET_CHALLENGE));
370
371
                // Add the socket information so we can reference it easily
372
                $sockets[(int) $socket->get()] = [
373
                    'server_id' => $server_id,
374
                    'socket'    => $socket,
375
                ];
376
377
                unset($socket);
378
379
                // Let's sleep shortly so we are not hammering out calls rapid fire style hogging cpu
380
                usleep($this->write_wait);
381
            }
382
        }
383
384
        // We have at least one server with a challenge, we need to listen for responses
385
        if ($server_challenge) {
386
            // Now we need to listen for and grab challenge response(s)
387
            $responses = call_user_func_array(
388
                [ $this->query, 'getResponses' ],
389
                [ $sockets, $this->timeout, $this->stream_timeout ]
390
            );
391
392
            // Iterate over the challenge responses
393
            foreach ($responses as $socket_id => $response) {
394
                // Back out the server_id we need to update the challenge response for
395
                $server_id = $sockets[$socket_id]['server_id'];
396
397
                // Make this into a buffer so it is easier to manipulate
398
                $challenge = new Buffer(implode('', $response));
399
400
                // Grab the server instance
401
                /* @var $server \GameQ\Server */
402
                $server = $this->servers[$server_id];
403
404
                // Apply the challenge
405
                $server->protocol()->challengeParseAndApply($challenge);
406
407
                // Add this socket to be reused, has to be reused in GameSpy3 for example
408
                $server->socketAdd($sockets[$socket_id]['socket']);
409
410
                // Clear
411
                unset($server);
412
            }
413
        }
414
    }
415
416
    /**
417
     * Run the actual queries and get the response(s)
418
     */
419
    protected function doQueries()
420
    {
421
422
        // Initialize the array of sockets
423
        $sockets = [ ];
424
425
        // Iterate over the server list
426
        foreach ($this->servers as $server_id => $server) {
427
            /* @var $server \GameQ\Server */
428
429
            // Invoke the beforeSend method
430
            $server->protocol()->beforeSend($server);
431
432
            // Get all the non-challenge packets we need to send
433
            $packets = $server->protocol()->getPacket('!' . Protocol::PACKET_CHALLENGE);
434
435
            if (count($packets) == 0) {
436
                // Skip nothing else to do for some reason.
437
                continue;
438
            }
439
440
            // Try to use an existing socket
441
            if (($socket = $server->socketGet()) === null) {
442
                // Let's make a clone of the query class
443
                $socket = clone $this->query;
444
445
                // Set the information for this query socket
446
                $socket->set(
447
                    $server->protocol()->transport(),
448
                    $server->ip,
449
                    $server->port_query,
450
                    $this->timeout
451
                );
452
            }
453
454
            // Iterate over all the packets we need to send
455
            foreach ($packets as $packet_data) {
456
                // Now write the packet to the socket.
457
                $socket->write($packet_data);
458
459
                // Let's sleep shortly so we are not hammering out calls rapid fire style
460
                usleep($this->write_wait);
461
            }
462
463
            unset($packets);
464
465
            // Add the socket information so we can reference it easily
466
            $sockets[(int) $socket->get()] = [
467
                'server_id' => $server_id,
468
                'socket'    => $socket,
469
            ];
470
471
            // Clean up the sockets, if any left over
472
            $server->socketCleanse();
473
        }
474
475
        // Now we need to listen for and grab response(s)
476
        $responses = call_user_func_array(
477
            [ $this->query, 'getResponses' ],
478
            [ $sockets, $this->timeout, $this->stream_timeout ]
479
        );
480
481
        // Iterate over the responses
482
        foreach ($responses as $socket_id => $response) {
483
            // Back out the server_id
484
            $server_id = $sockets[$socket_id]['server_id'];
485
486
            // Grab the server instance
487
            /* @var $server \GameQ\Server */
488
            $server = $this->servers[$server_id];
489
490
            // Save the response from this packet
491
            $server->protocol()->packetResponse($response);
492
493
            unset($server);
494
        }
495
496
        // Now we need to close all of the sockets
497
        foreach ($sockets as $socketInfo) {
498
            /* @var $socket \GameQ\Query\Core */
499
            $socket = $socketInfo['socket'];
500
501
            // Close the socket
502
            $socket->close();
503
504
            unset($socket);
505
        }
506
507
        unset($sockets);
508
    }
509
510
    /**
511
     * Parse the response for a specific server
512
     *
513
     * @param \GameQ\Server $server
514
     *
515
     * @return array
516
     * @throws \Exception
517
     */
518 118
    protected function doParseResponse(Server $server)
519
    {
520
521
        try {
522
            // @codeCoverageIgnoreStart
523
            // We want to save this server's response to a file (useful for unit testing)
524
            if (!is_null($this->capture_packets_file)) {
525
                file_put_contents(
526
                    $this->capture_packets_file,
527
                    implode(PHP_EOL . '||' . PHP_EOL, $server->protocol()->packetResponse())
528
                );
529
            }
530
            // @codeCoverageIgnoreEnd
531
532
            // Get the server response
533 118
            $results = $server->protocol()->processResponse();
534
535
            // Check for online before we do anything else
536 97
            $results['gq_online'] = (count($results) > 0);
537 118
        } catch (ProtocolException $e) {
538
            // Check to see if we are in debug, if so bubble up the exception
539 21
            if ($this->debug) {
540 13
                throw new \Exception($e->getMessage(), $e->getCode(), $e);
541
            }
542
543
            // We ignore this server
544
            $results = [
545 8
                'gq_online' => false,
546 8
            ];
547
        }
548
549
        // Now add some default stuff
550 105
        $results['gq_address'] = $server->ip();
551 105
        $results['gq_port_client'] = $server->portClient();
552 105
        $results['gq_port_query'] = $server->portQuery();
553 105
        $results['gq_protocol'] = $server->protocol()->getProtocol();
554 105
        $results['gq_type'] = (string) $server->protocol();
555 105
        $results['gq_name'] = $server->protocol()->nameLong();
556 105
        $results['gq_transport'] = $server->protocol()->transport();
557
558
        // Process the join link
559 105
        if (!isset($results['gq_joinlink']) || empty($results['gq_joinlink'])) {
560 105
            $results['gq_joinlink'] = $server->getJoinLink();
561 105
        }
562
563 105
        return $results;
564
    }
565
566
    /**
567
     * Apply any filters to the results
568
     *
569
     * @param array         $results
570
     * @param \GameQ\Server $server
571
     *
572
     * @return array
573
     */
574 2
    protected function doApplyFilters(array $results, Server $server)
575
    {
576
577
        // Loop over the filters
578 2
        foreach ($this->options['filters'] as $filterName => $filterOptions) {
579
            // Try to do this filter
580
            try {
581
                // Make a new reflection class
582 2
                $class = new \ReflectionClass(sprintf('GameQ\\Filters\\%s', ucfirst($filterName)));
583
584
                // Create a new instance of the filter class specified
585 1
                $filter = $class->newInstanceArgs([ $filterOptions ]);
586
587
                // Apply the filter to the data
588 1
                $results = $filter->apply($results, $server);
589 2
            } catch (\ReflectionException $e) {
590
                // Invalid, skip it
591 1
                continue;
592
            }
593 2
        }
594
595 2
        return $results;
596
    }
597
}
598