Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
96.58% |
141 / 146 |
|
90.48% |
19 / 21 |
CRAP | |
0.00% |
0 / 1 |
| CSQuery | |
96.58% |
141 / 146 |
|
90.48% |
19 / 21 |
51 | |
0.00% |
0 / 1 |
| __construct | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
1 | |||
| __sleep | |
100.00% |
22 / 22 |
|
100.00% |
1 / 1 |
1 | |||
| getProtocolsMap | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| getSupportedProtocols | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| getProtocolClass | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| createInstance | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
2 | |||
| setUdpClient | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| unserialize | |
100.00% |
11 / 11 |
|
100.00% |
1 / 1 |
5 | |||
| getNativeJoinURI | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| query_server | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
| sortPlayers | |
66.67% |
8 / 12 |
|
0.00% |
0 / 1 |
12.00 | |||
| toJson | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| toHtml | |
94.12% |
16 / 17 |
|
0.00% |
0 / 1 |
5.01 | |||
| reset | |
100.00% |
21 / 21 |
|
100.00% |
1 / 1 |
1 | |||
| sendCommand | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
2 | |||
| sortByName | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
3 | |||
| sortByScore | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
3 | |||
| sortByFrags | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
3 | |||
| sortByDeaths | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
3 | |||
| sortByTime | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
3 | |||
| sortByKills | |
100.00% |
7 / 7 |
|
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 | |
| 13 | namespace Clansuite\ServerQuery; |
| 14 | |
| 15 | use function base64_decode; |
| 16 | use function class_exists; |
| 17 | use function count; |
| 18 | use function htmlspecialchars; |
| 19 | use function is_string; |
| 20 | use function json_encode; |
| 21 | use function preg_match; |
| 22 | use function serialize; |
| 23 | use function strcasecmp; |
| 24 | use function strlen; |
| 25 | use function substr; |
| 26 | use function uasort; |
| 27 | use function unserialize; |
| 28 | use Clansuite\ServerQuery\Util\UdpClient; |
| 29 | use InvalidArgumentException; |
| 30 | use RuntimeException; |
| 31 | |
| 32 | /** |
| 33 | * Base class for game server query protocols. |
| 34 | */ |
| 35 | class 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 | } |