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: }
607: