Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
96.58% covered (success)
96.58%
141 / 146
90.48% covered (success)
90.48%
19 / 21
CRAP
0.00% covered (danger)
0.00%
0 / 1
CSQuery
96.58% covered (success)
96.58%
141 / 146
90.48% covered (success)
90.48%
19 / 21
51
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 __sleep
100.00% covered (success)
100.00%
22 / 22
100.00% covered (success)
100.00%
1 / 1
1
 getProtocolsMap
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getSupportedProtocols
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getProtocolClass
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 createInstance
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 setUdpClient
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 unserialize
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
5
 getNativeJoinURI
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 query_server
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 sortPlayers
66.67% covered (warning)
66.67%
8 / 12
0.00% covered (danger)
0.00%
0 / 1
12.00
 toJson
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 toHtml
94.12% covered (success)
94.12%
16 / 17
0.00% covered (danger)
0.00%
0 / 1
5.01
 reset
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
1
 sendCommand
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 sortByName
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 sortByScore
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 sortByFrags
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 sortByDeaths
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 sortByTime
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 sortByKills
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
1<?php declare(strict_types=1);
2
3/**
4 * Clansuite Server Query
5 *
6 * SPDX-FileCopyrightText: 2003-2025 Jens A. Koch
7 * SPDX-License-Identifier: MIT
8 *
9 * For the full copyright and license information, please view
10 * the LICENSE file that was distributed with this source code.
11 */
12
13namespace Clansuite\ServerQuery;
14
15use function base64_decode;
16use function class_exists;
17use function count;
18use function htmlspecialchars;
19use function is_string;
20use function json_encode;
21use function preg_match;
22use function serialize;
23use function strcasecmp;
24use function strlen;
25use function substr;
26use function uasort;
27use function unserialize;
28use Clansuite\ServerQuery\Util\UdpClient;
29use InvalidArgumentException;
30use RuntimeException;
31
32/**
33 * Base class for game server query protocols.
34 */
35class CSQuery
36{
37    /** ip or hostname of the server */
38    public ?string $address = null;
39
40    /**  port to use for the query */
41    public ?int $queryport = null;
42
43    /**  the port you have to connect to enter the game */
44    public int $hostport = 0;
45
46    /**
47     *  status of the server.
48     *
49     * TRUE: server online, FALSE: server offline
50     */
51    public bool $online;
52
53    /**  the name of the game */
54    public string $gamename;
55
56    /**  the version of the game */
57    public string $gameversion;
58
59    /**
60     * Protocol identifier.
61     */
62    public string $protocol = 'Base';
63
64    /**  The title of the server */
65    public string $servertitle;
66
67    /**  The name of the map (often corresponds with the filename of the map)*/
68    public string $mapname;
69
70    /**  A more descriptive name of the map */
71    public string $maptitle;
72
73    /**  The gametype */
74    public string $gametype;
75
76    /**  current number of players on the server */
77    public int $numplayers;
78
79    /**  maximum number of players allowed on the server */
80    public int $maxplayers;
81    public int $steamAppID;
82
83    /**
84     *  Wheather the game server is password protected.
85     *
86     *  1: server is password protected<br>
87     *  0: server is not password protected<br>
88     * -1: unknown
89     */
90    public int $password = -1;
91
92    /**  next map on the server */
93    public string $nextmap = '';
94
95    /**
96     * Players playing on the server.
97     *
98     * @see playerkeys
99     *
100     * Hash with player ids as key.
101     * The containing value will be another hash with the infos of the player.
102     * To access a player name use <code>players[$playerid]['name']</code>.
103     * Check playerkeys to get the keys available
104     *
105     * @var array<array<string, mixed>>
106     */
107    public array $players = [];
108
109    /**
110     * Hash of available player infos.
111     *
112     * There is a key for each player info available (e.g. name, score, ping etc).
113     * The value is TRUE if the info is available
114     *
115     * @var array<string, bool>
116     */
117    public array $playerkeys;
118
119    /**  list of the team names.
120     * @var array<array<string, mixed>>
121     */
122    public array $playerteams = [];
123
124    /**  a list of all maps in cycle.
125     * @var array<string>
126     */
127    public array $maplist = [];
128
129    /**
130     * Hash with all server rules.
131     *
132     * key:   rulename<br>
133     * value: rulevalue
134     *
135     * @var array<string, mixed>
136     */
137    public array $rules = [];
138
139    /**  Short errormessage if something goes wrong */
140    public string $errstr = '';
141
142    /**  Response time in milliseconds */
143    public float $response = 0.0;
144    public string $name    = '';
145
146    /**
147     * List of supported games.
148     *
149     * @var array<string>
150     */
151    public array $supportedGames = [];
152
153    /**
154     * List of supported game series.
155     *
156     * @var array<string>
157     */
158    public array $game_series_list = [];
159
160    /**
161     *  Array with debug infos.
162     *
163     * Stores all the send/received data
164     * Format: send data => received data
165     *
166     * @var array<mixed>
167     */
168    public array $debug;
169    protected UdpClient $udpClient;
170
171    /**
172     *  Initializes the CSQuery instance.
173     */
174    public function __construct(?string $address = null, ?int $queryport = null)
175    {
176        $this->address   = $address;
177        $this->queryport = $queryport;
178        $this->udpClient = new UdpClient;
179        // clear publics
180        $this->reset();
181    }
182
183    /**
184     * Returns a list of property names to serialize for object serialization.
185     *
186     * @return array list of property names to serialize
187     */
188    public function __sleep()
189    {
190        // do not serialize debug info to keep the result small
191        return [
192            'address',
193            'queryport',
194            'gamename',
195            'hostport',
196            'online',
197            'gameversion',
198            'servertitle',
199            'mapname',
200            'maptitle',
201            'gametype',
202            'numplayers',
203            'maxplayers',
204            'password',
205            'nextmap',
206            'players',
207            'playerkeys',
208            'playerteams',
209            'maplist',
210            'rules',
211            'errstr',
212        ];
213    }
214
215    /**
216     * Returns a map of supported protocols to their implementing class names.
217     *
218     * @return array<string, string> An array with names of the supported protocols
219     */
220    public function getProtocolsMap(): array
221    {
222        return ServerProtocols::getProtocolsMap();
223    }
224
225    /**
226     * Return the names of the supported server protocols.
227     *
228     * @return array<string> An array with names of the supported protocols
229     */
230    public function getSupportedProtocols(): array
231    {
232        return ServerProtocols::getSupportedProtocols();
233    }
234
235    /**
236     * Return the class name for a given protocol.
237     *
238     * @param string $protocolClassname the protocol name
239     *
240     * @return string the class name for the protocol
241     */
242    public function getProtocolClass(string $protocolClassname): mixed
243    {
244        return ServerProtocols::getProtocolClass($protocolClassname);
245    }
246
247    /**
248     * Create a new instance of a protocol-specific CSQuery subclass.
249     *
250     * @param string $protocol the protocol name (e.g. 'Csgo', 'Steam')
251     * @param string $address  the server address
252     * @param int    $port     the query port
253     *
254     * @throws InvalidArgumentException if the protocol is not supported
255     *
256     * @return CSQuery a CSQuery object that supports the specified protocol
257     */
258    public function createInstance(string $protocol, string $address, int $port): self
259    {
260        $className = $this->getProtocolClass($protocol);
261
262        /** @var class-string<CSQuery> $className */
263        if (!class_exists($className)) {
264            throw new InvalidArgumentException("Protocol '{$protocol}' is not supported.");
265        }
266
267        return new $className($address, $port);
268    }
269
270    /**
271     * Set a custom UDP client (for testing with fixtures).
272     *
273     * @param UdpClient $udpClient the UDP client to set
274     */
275    public function setUdpClient(UdpClient $udpClient): void
276    {
277        $this->udpClient = $udpClient;
278    }
279
280    /**
281     * Use this to restore a object that has been previously serialized with
282     * serialize.
283     *
284     * @param string $string serialized CSQuery object
285     *
286     * @return mixed the deserialized data
287     */
288    public function unserialize(string $string): mixed
289    {
290        // extracting class name
291        $length = strlen($string);
292        $i      = 0;
293
294        for (; $i < $length; $i++) {
295            if ($string[$i] === ':') {
296                break;
297            }
298        }
299
300        $className = substr($string, 0, $i);
301
302        // we should be careful when using eval with supplied arguments
303        if (preg_match('/^[A-Za-z0-9_-]+$/D', $className) !== false) {
304            // In the new structure, classes are autoloaded via composer
305            // include_once is not needed
306        }
307
308        $data = base64_decode(substr($string, $i + 1), true);
309
310        if ($data === false) {
311            throw new RuntimeException('Invalid base64 data in serialized string');
312        }
313
314        return unserialize($data);
315    }
316
317    /**
318     * Returns a native join URI.
319     *
320     * Some games are registering an URI type to allow easy joining of games
321     *
322     * @return false|string a native join URI or false if not implemented for the game
323     */
324    public function getNativeJoinURI(): false|string
325    {
326        return false;
327    }
328
329    /**
330     * Queries the server.
331     *
332     * @param bool $getPlayers whether to retrieve player infos
333     * @param bool $getRules   whether to retrieve rules
334     *
335     * @return bool true on success
336     */
337    public function query_server(bool $getPlayers = true, bool $getRules = true): bool
338    {
339        $this->errstr = 'This class cannot be used to query a server';
340
341        return false;
342    }
343
344    /**
345     * Sorts the given players.
346     *
347     * You can sort by name, score, frags, deaths, honor and time
348     *
349     * @param array<array<string, mixed>> $players players to sort
350     * @param string                      $sortkey sort by the given key
351     *
352     * @return array<array<string, mixed>> sorted player hash
353     */
354    public function sortPlayers(array $players, string $sortkey = 'name'): array
355    {
356        if (count($players) === 0) {
357            return [];
358        }
359
360        match ($sortkey) {
361            'name'   => uasort($players, [$this, 'sortByName']),
362            'score'  => uasort($players, [$this, 'sortByScore']),
363            'frags'  => uasort($players, [$this, 'sortByFrags']),
364            'deaths' => uasort($players, [$this, 'sortByDeaths']),
365            'kills'  => uasort($players, [$this, 'sortByKills']),
366            'time'   => uasort($players, [$this, 'sortByTime']),
367            default  => $players,
368        };
369
370        return $players;
371    }
372
373    /**
374     * Returns the server data as JSON string.
375     */
376    public function toJson(): false|string
377    {
378        return json_encode($this);
379    }
380
381    /**
382     * Returns the server data as HTML string.
383     */
384    public function toHtml(): string
385    {
386        $html = '<!DOCTYPE html><html><head><title>Server Info</title></head><body>';
387        $html .= '<h1>' . htmlspecialchars($this->servertitle) . '</h1>';
388        $html .= '<p>Address: ' . htmlspecialchars($this->address ?? '') . ':' . $this->hostport . '</p>';
389        $html .= '<p>Game: ' . htmlspecialchars($this->gamename) . '</p>';
390        $html .= '<p>Map: ' . htmlspecialchars($this->mapname) . '</p>';
391        $html .= '<p>Players: ' . $this->numplayers . '/' . $this->maxplayers . '</p>';
392        $html .= '<p>Online: ' . ($this->online ? 'Yes' : 'No') . '</p>';
393
394        if ($this->players !== []) {
395            $html .= '<h2>Players</h2><ul>';
396
397            foreach ($this->players as $player) {
398                $name = $player['name'] ?? 'Unknown';
399
400                if (!is_string($name)) {
401                    $name = 'Unknown';
402                }
403                $html .= '<li>' . htmlspecialchars($name) . '</li>';
404            }
405            $html .= '</ul>';
406        }
407        $html .= '</body></html>';
408
409        return $html;
410    }
411
412    /**
413     * Resets the object to its initial state.
414     */
415    protected function reset(): void
416    {
417        // Ensure address and query port have sensible defaults so typed properties
418        // are initialized and safe to access immediately after construction.
419        // Note: address and queryport are set in constructor and should not be reset
420        $this->online      = false;
421        $this->gamename    = '';
422        $this->hostport    = 0;
423        $this->gameversion = '';
424        $this->servertitle = '';
425        $this->mapname     = '';
426        $this->maptitle    = '';
427        $this->gametype    = '';
428        $this->numplayers  = 0;
429        $this->maxplayers  = 0;
430        $this->password    = -1;
431        $this->nextmap     = '';
432        $this->players     = [];
433        $this->playerkeys  = [];
434        $this->playerteams = [];
435        $this->maplist     = [];
436        $this->rules       = [];
437        $this->errstr      = '';
438        $this->response    = 0.0;
439        $this->debug       = [];
440        $this->steamAppID  = 0;
441    }
442
443    /**
444     * Send a command to the server.
445     *
446     * @param string $address Server address
447     * @param int    $port    Server port
448     * @param string $command Command to send
449     *
450     * @return false|string The response or false on error
451     */
452    protected function sendCommand(string $address, int $port, string $command): false|string
453    {
454        $this->debug[] = '-> ' . $command;
455
456        $result = $this->udpClient->query($address, $port, $command);
457
458        if ($result === null) {
459            $this->debug[] = '<- (no response)';
460
461            return false;
462        }
463
464        $this->debug[] = '<- ' . $result;
465
466        return $result;
467    }
468
469    /**
470     * Sorting helper for player names.
471     *
472     * @param array<string, mixed> $a first player
473     * @param array<string, mixed> $b second player
474     *
475     * @return int comparison result
476     */
477    private function sortByName(array $a, array $b): int
478    {
479        $nameA = $a['name'] ?? '';
480        $nameA = is_string($nameA) ? $nameA : '';
481        $nameB = $b['name'] ?? '';
482        $nameB = is_string($nameB) ? $nameB : '';
483
484        return strcasecmp($nameA, $nameB);
485    }
486
487    /**
488     * Sorting helper for player scores.
489     *
490     * @param array<string, mixed> $a first player
491     * @param array<string, mixed> $b second player
492     *
493     * @return int comparison result
494     */
495    private function sortByScore(array $a, array $b): int
496    {
497        $scoreA = $a['score'] ?? 0;
498        $scoreB = $b['score'] ?? 0;
499
500        if ($scoreA === $scoreB) {
501            return 0;
502        }
503
504        if ($scoreA < $scoreB) {
505            return 1;
506        }
507
508        return -1;
509    }
510
511    /**
512     * Sorting helper for player frags.
513     *
514     * @param array<string, mixed> $a first player
515     * @param array<string, mixed> $b second player
516     *
517     * @return int comparison result
518     */
519    private function sortByFrags(array $a, array $b): int
520    {
521        $fragsA = $a['frags'] ?? 0;
522        $fragsB = $b['frags'] ?? 0;
523
524        if ($fragsA === $fragsB) {
525            return 0;
526        }
527
528        if ($fragsA < $fragsB) {
529            return 1;
530        }
531
532        return -1;
533    }
534
535    /**
536     * Sorting helper for player deaths.
537     *
538     * @param array<string, mixed> $a first player
539     * @param array<string, mixed> $b second player
540     *
541     * @return int comparison result
542     */
543    private function sortByDeaths(array $a, array $b): int
544    {
545        $deathsA = $a['deaths'] ?? 0;
546        $deathsB = $b['deaths'] ?? 0;
547
548        if ($deathsA === $deathsB) {
549            return 0;
550        }
551
552        if ($deathsA < $deathsB) {
553            return 1;
554        }
555
556        return -1;
557    }
558
559    /**
560     * Sorting helper for player time.
561     *
562     * @param array<string, mixed> $a first player
563     * @param array<string, mixed> $b second player
564     *
565     * @return int comparison result
566     */
567    private function sortByTime(array $a, array $b): int
568    {
569        $timeA = $a['time'] ?? 0;
570        $timeB = $b['time'] ?? 0;
571
572        if ($timeA === $timeB) {
573            return 0;
574        }
575
576        if ($timeA < $timeB) {
577            return 1;
578        }
579
580        return -1;
581    }
582
583    /**
584     * Sorting helper for player kills.
585     *
586     * @param array<string, mixed> $a first player
587     * @param array<string, mixed> $b second player
588     *
589     * @return int comparison result
590     */
591    private function sortByKills(array $a, array $b): int
592    {
593        $killsA = $a['kills'] ?? 0;
594        $killsB = $b['kills'] ?? 0;
595
596        if ($killsA === $killsB) {
597            return 0;
598        }
599
600        if ($killsA < $killsB) {
601            return 1;
602        }
603
604        return -1;
605    }
606}