Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
5.15% covered (danger)
5.15%
7 / 136
0.00% covered (danger)
0.00%
0 / 2
CRAP
0.00% covered (danger)
0.00%
0 / 1
Quake3Arena
5.15% covered (danger)
5.15%
7 / 136
0.00% covered (danger)
0.00%
0 / 2
3897.91
0.00% covered (danger)
0.00%
0 / 1
 query_server
7.69% covered (danger)
7.69%
7 / 91
0.00% covered (danger)
0.00%
0 / 1
889.53
 htmlize
0.00% covered (danger)
0.00%
0 / 45
0.00% covered (danger)
0.00%
0 / 1
1190
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\ServerProtocols;
14
15use function count;
16use function explode;
17use function htmlentities;
18use function is_string;
19use function preg_match;
20use function preg_replace;
21use function preg_split;
22use function sprintf;
23use function str_repeat;
24use function strlen;
25use function strtolower;
26use function substr;
27use Override;
28
29/**
30 * Implements the query protocol for Quake 3 Arena servers.
31 * Handles server information retrieval, player lists, and game-specific data parsing.
32 */
33class Quake3Arena extends Quake
34{
35    /**
36     * Protocol name.
37     */
38    public string $name = 'Quake 3 Arena';
39
40    /**
41     * List of supported games.
42     *
43     * @var array<string>
44     */
45    public array $supportedGames = ['Quake 3 Arena', 'Warsow', 'Call of Duty', 'Call of Duty 2', 'Call of Duty 4'];
46
47    /**
48     * Protocol identifier.
49     */
50    public string $protocol = 'Quake3';
51
52    /**
53     * Game series.
54     *
55     * @var array<string>
56     */
57    public array $game_series_list = ['Quake'];
58
59    /**
60     * query_server method.
61     */
62    #[Override]
63    public function query_server(bool $getPlayers = true, bool $getRules = true): bool
64    {
65        if ($this->online) {
66            $this->reset();
67        }
68
69        $address = (string) $this->address;
70        $port    = (int) $this->queryport;
71
72        $command = "\xFF\xFF\xFF\xFF\x02getstatus\x0a\x00";
73
74        if (($result = $this->sendCommand($address, $port, $command)) === '' || ($result = $this->sendCommand($address, $port, $command)) === '0' || ($result = $this->sendCommand($address, $port, $command)) === false) {
75            $this->errstr = 'No reply received';
76
77            return false;
78        }
79
80        $temp    = explode("\x0a", $result);
81        $rawdata = [];
82
83        if (isset($temp[1]) && is_string($temp[1])) {
84            $rawdata = explode('\\', substr($temp[1], 1, strlen($temp[1])));
85        }
86
87        // get rules and basic infos
88        for ($i = 0; $i < count($rawdata); $i++) {
89            $key = $rawdata[$i];
90            $i++;
91
92            switch ($key) {
93                case 'g_gametypestring':
94                    $this->gametype = $rawdata[$i] ?? '';
95
96                    break;
97
98                case 'gamename':
99                    $this->gametype = $rawdata[$i] ?? '';
100
101                    $this->gamename = 'q3a_' . preg_replace('/[ :]/', '_', strtolower($rawdata[$i] ?? ''));
102
103                    break;
104
105                case 'version':
106                    // for CoD
107                case 'shortversion':
108                    $this->gameversion = $rawdata[$i] ?? '';
109
110                    break;
111
112                case 'sv_hostname':
113                    $this->servertitle = $rawdata[$i] ?? '';
114
115                    break;
116
117                case 'mapname':
118                    $this->mapname = $rawdata[$i] ?? '';
119
120                    break;
121
122                case 'g_needpass':
123                    // for CoD
124                case 'pswrd':
125                    $this->password = isset($rawdata[$i]) ? (int) $rawdata[$i] : 0;
126
127                    break;
128
129                case 'sv_maplist':
130                    $tmp           = preg_split('#( )+#', $rawdata[$i] ?? '');
131                    $tmp           = $tmp !== false ? $tmp : [];
132                    $this->maplist = $tmp;
133
134                    break;
135
136                case 'sv_privateclients':
137                    $this->rules['sv_privateClients'] = $rawdata[$i] ?? 0;
138
139                    break;
140
141                default:
142                    $this->rules[$rawdata[$i - 1] ?? ''] = $rawdata[$i] ?? '';
143            }
144        }
145
146        // for MoHAA
147        if ($this->gamename === '' && preg_match('/Medal of Honor/Di', $this->gameversion) === 1) {
148            $this->gamename = 'mohaa';
149        }
150
151        if (count($this->maplist) > 0) {
152            $i             = 0;
153            $this->nextmap = $this->maplist[$i % count($this->maplist)] ?? '';
154        }
155
156        // for MoHAA
157        $this->mapname = preg_replace('/.*\//', '', $this->mapname) ?? $this->mapname;
158
159        $this->hostport   = $this->queryport ?? 0;
160        $this->maxplayers = (int) ($this->rules['sv_maxclients'] ?? 0) - (int) ($this->rules['sv_privateClients'] ?? 0);
161
162        // get playerdata
163        $temp             = substr($result, strlen($temp[0] ?? '') + strlen($temp[1] ?? '') + 1, strlen($result));
164        $allplayers       = explode("\n", $temp);
165        $this->numplayers = count($allplayers) - 2;
166
167        // get players
168        if (count($allplayers) - 2 > 0 && $getPlayers) {
169            $players  = [];
170            $pingOnly = false;
171            $teamInfo = false;
172
173            for ($i = 1; $i < count($allplayers) - 1; $i++) {
174                $line = $allplayers[$i] ?? '';
175
176                // match with team info
177                if (preg_match("/(\d+)[^0-9](\d+)[^0-9]\"(.*)\"/", $line, $curplayer) !== false) {
178                    // ignore spectators (team > 2)
179                    /** @phpstan-ignore offsetAccess.notFound */
180                    if ((int) $curplayer[3] > 2) {
181                        // ignore spectators
182                    }
183
184                    /** @phpstan-ignore offsetAccess.notFound */
185                    $players[$i - 1]['name'] = $curplayer[3];
186
187                    /** @phpstan-ignore offsetAccess.notFound */
188                    $players[$i - 1]['score'] = (int) $curplayer[1];
189
190                    /** @phpstan-ignore offsetAccess.notFound */
191                    $players[$i - 1]['ping'] = (int) $curplayer[2];
192
193                    /** @phpstan-ignore offsetAccess.notFound */
194                    $players[$i - 1]['team'] = $curplayer[3];
195                    $teamInfo                = true;
196                    $pingOnly                = false;
197                }
198
199                /** @phpstan-ignore notIdentical.alwaysFalse */ elseif (preg_match("/(\d+)[^0-9](\d+)[^0-9]\"(.*)\"/", $line, $curplayer) !== false) {
200                    /** @phpstan-ignore offsetAccess.notFound */
201                    $players[$i - 1]['name'] = $curplayer[3];
202
203                    /** @phpstan-ignore offsetAccess.notFound */
204                    $players[$i - 1]['score'] = (int) $curplayer[1];
205
206                    /** @phpstan-ignore offsetAccess.notFound */
207                    $players[$i - 1]['ping'] = (int) $curplayer[2];
208                    $pingOnly                = false;
209                    $teamInfo                = false;
210                } else {
211                    if (preg_match("/(\d+).\"(.*)\"/", $line, $curplayer) !== false) {
212                        /** @phpstan-ignore offsetAccess.notFound */
213                        $players[$i - 1]['name'] = $curplayer[2];
214
215                        /** @phpstan-ignore offsetAccess.notFound */
216                        $players[$i - 1]['ping'] = (int) $curplayer[1];
217                        $pingOnly                = true; // for MoHAA
218                    } else {
219                        $this->errstr = 'Could not extract player infos!';
220
221                        return false;
222                    }
223                }
224            }
225            $this->playerkeys['name'] = true;
226
227            if (!$pingOnly) {
228                $this->playerkeys['score'] = true;
229
230                if ($teamInfo) {
231                    $this->playerkeys['team'] = true;
232                }
233            }
234            $this->playerkeys['ping'] = true;
235            $this->players            = $players;
236        }
237
238        $this->online = true;
239
240        return true;
241    }
242
243    /**
244     *  htmlizes the given raw string.
245     *
246     * @param string $var a raw string from the gameserver that might contain special chars
247     */
248    public function htmlize(string $var): string
249    {
250        $len     = strlen($var);
251        $numTags = 0;
252        $result  = '';
253        $var .= '  '; // padding
254        $colortag = '<span class="csQuery-%s-%s">';
255
256        $csstype = match ($this->gamename) {
257            'q3a_Call_of_Duty', 'q3a_sof2' => 'q3a_exdended',
258            default => 'q3a',
259        };
260
261        for ($i = 0; $i < $len; $i++) {
262            // checking for a color code
263            if ($var[$i] === '^') {
264                $numTags++; // count tags
265
266                match ($var[++$i]) {
267                    '<'     => $result .= sprintf($colortag, $csstype, 'less'),
268                    '>'     => $result .= sprintf($colortag, $csstype, 'greater'),
269                    '&'     => $result .= sprintf($colortag, $csstype, 'and'),
270                    '\''    => $result .= sprintf($colortag, $csstype, 'tick'),
271                    '='     => $result .= sprintf($colortag, $csstype, 'equal'),
272                    '?'     => $result .= sprintf($colortag, $csstype, 'questionmark'),
273                    '.'     => $result .= sprintf($colortag, $csstype, 'point'),
274                    ','     => $result .= sprintf($colortag, $csstype, 'comma'),
275                    '!'     => $result .= sprintf($colortag, $csstype, 'exc'),
276                    '*'     => $result .= sprintf($colortag, $csstype, 'star'),
277                    '$'     => $result .= sprintf($colortag, $csstype, 'dollar'),
278                    '#'     => $result .= sprintf($colortag, $csstype, 'pound'),
279                    '('     => $result .= sprintf($colortag, $csstype, 'lparen'),
280                    ')'     => $result .= sprintf($colortag, $csstype, 'rparen'),
281                    '@'     => $result .= sprintf($colortag, $csstype, 'at'),
282                    '%'     => $result .= sprintf($colortag, $csstype, 'percent'),
283                    '+'     => $result .= sprintf($colortag, $csstype, 'plus'),
284                    '|'     => $result .= sprintf($colortag, $csstype, 'bar'),
285                    '{'     => $result .= sprintf($colortag, $csstype, 'lbracket'),
286                    '}'     => $result .= sprintf($colortag, $csstype, 'rbracket'),
287                    '"'     => $result .= sprintf($colortag, $csstype, 'quote'),
288                    ':'     => $result .= sprintf($colortag, $csstype, 'colon'),
289                    '['     => $result .= sprintf($colortag, $csstype, 'lsqr'),
290                    ']'     => $result .= sprintf($colortag, $csstype, 'rsqr'),
291                    '\\'    => $result .= sprintf($colortag, $csstype, 'lslash'),
292                    '/'     => $result .= sprintf($colortag, $csstype, 'rslash'),
293                    ';'     => $result .= sprintf($colortag, $csstype, 'semic'),
294                    '^'     => $result .= '^<span class="csQuery-' . $csstype . '-' . $var[++$i] . '">',
295                    default => $result .= sprintf($colortag, $csstype, $var[$i]),
296                };
297            } else {
298                // normal char
299                $result .= htmlentities($var[$i]);
300            }
301        }
302
303        // appending numTags spans
304        return $result . str_repeat('</span>', $numTags);
305    }
306}