Completed
Push — v3 ( 8e3ad2...4026e6 )
by Austin
04:46
created

GameQ::addServers()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 10
Code Lines 4

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 4
CRAP Score 2

Importance

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