Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
3.06% covered (danger)
3.06%
3 / 98
12.50% covered (danger)
12.50%
1 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
Quake
3.06% covered (danger)
3.06%
3 / 98
12.50% covered (danger)
12.50%
1 / 8
1150.91
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 query_server
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
90
 rcon_query_server
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
20
 query
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
2
 getProtocolName
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getVersion
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 parseServerInfo
0.00% covered (danger)
0.00%
0 / 29
0.00% covered (danger)
0.00%
0 / 1
156
 parsePlayers
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
42
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/**
14 *  Abstract class that implements quake related stuff.
15 *
16 * Implements everything that all quake protocols have in common
17 */
18
19namespace Clansuite\ServerQuery\ServerProtocols;
20
21use const PREG_SPLIT_NO_EMPTY;
22use function array_shift;
23use function array_slice;
24use function count;
25use function explode;
26use function preg_split;
27use function trim;
28use Clansuite\Capture\Protocol\ProtocolInterface;
29use Clansuite\Capture\ServerAddress;
30use Clansuite\Capture\ServerInfo;
31use Clansuite\ServerQuery\CSQuery;
32use Override;
33
34/**
35 * Provides the base query protocol implementation for Quake engine-based games.
36 * Handles common query operations, data parsing, and structures shared across Quake variants.
37 */
38class Quake extends CSQuery implements ProtocolInterface
39{
40    /**
41     * Protocol name.
42     */
43    public string $name = 'Quake';
44
45    /**
46     * List of supported games.
47     */
48    public array $supportedGames = ['Quake', 'Quake 2', 'Quake 3 Arena', 'Quake 4'];
49
50    /**
51     * Protocol identifier.
52     */
53    public string $protocol = 'Quake';
54
55    /**
56     * Game series.
57     */
58    public array $game_series_list = ['Quake'];
59
60    /**
61     * Constructor.
62     *
63     * Initializes the Quake protocol instance with server address and query port.
64     *
65     * @param null|string $address   Server IP address or hostname
66     * @param null|int    $queryport Query port number
67     */
68    public function __construct(?string $address = null, ?int $queryport = null)
69    {
70        parent::__construct();
71        $this->address   = $address;
72        $this->queryport = $queryport;
73    }
74
75    /**
76     * Queries the server for information, optionally including players and rules.
77     *
78     * @param bool $getPlayers Whether to retrieve the player list
79     * @param bool $getRules   Whether to retrieve server rules
80     *
81     * @return bool True on successful query, false on failure
82     */
83    #[Override]
84    public function query_server(bool $getPlayers = true, bool $getRules = true): bool
85    {
86        if ($this->online) {
87            $this->reset();
88        }
89
90        $address = (string) $this->address;
91        $port    = (int) $this->queryport;
92
93        // Quake1 uses simple text commands - try different ones
94        $commands = ["status\n", "info\n", "ping\n"];
95
96        $result = false;
97
98        foreach ($commands as $command) {
99            if (($result = $this->sendCommand($address, $port, $command)) !== false) {
100                break;
101            }
102        }
103
104        if ($result === '' || $result === '0' || $result === false) {
105            $this->errstr = 'No reply received';
106
107            return false;
108        }
109
110        // Parse the Quake1 response
111        // Format: first line is server info, subsequent lines are players
112        $lines = explode("\n", trim($result));
113
114        // First line contains server info in key\value format
115        $serverInfo = $lines[0];
116        $this->parseServerInfo($serverInfo);
117
118        // Remaining lines are players (if any)
119        if ($getPlayers && count($lines) > 1) {
120            $this->parsePlayers(array_slice($lines, 1));
121        }
122
123        $this->online = true;
124
125        return true;
126    }
127
128    /**
129     *  Sends a rcon command to the game server.
130     *
131     * @param string $command  the command to send
132     * @param string $rcon_pwd rcon password to authenticate with
133     */
134    public function rcon_query_server(string $command, string $rcon_pwd): false|string
135    {
136        $address = (string) $this->address;
137        $port    = (int) $this->queryport;
138
139        $command = "\xFF\xFF\xFF\xFF\x02rcon " . $rcon_pwd . ' ' . $command . "\x0a\x00";
140
141        if (($result = $this->sendCommand($address, $port, $command)) === '' || ($result = $this->sendCommand($address, $port, $command)) === '0' || ($result = $this->sendCommand($address, $port, $command)) === false) {
142            $this->errstr                            = 'Error sending rcon command';
143            $this->debug['Command send ' . $command] = 'No reply received';
144
145            return false;
146        }
147
148        return $result;
149    }
150
151    /**
152     * query method.
153     */
154    #[Override]
155    public function query(ServerAddress $addr): ServerInfo
156    {
157        $this->address   = $addr->ip;
158        $this->queryport = $addr->port;
159        $this->query_server(true, true);
160
161        return new ServerInfo(
162            address: $this->address,
163            queryport: $this->queryport,
164            online: $this->online,
165            gamename: $this->gamename,
166            gameversion: $this->gameversion,
167            servertitle: $this->servertitle,
168            mapname: $this->mapname,
169            gametype: $this->gametype,
170            numplayers: $this->numplayers,
171            maxplayers: $this->maxplayers,
172            rules: $this->rules,
173            players: $this->players,
174        );
175    }
176
177    /**
178     * getProtocolName method.
179     */
180    #[Override]
181    public function getProtocolName(): string
182    {
183        return $this->protocol;
184    }
185
186    /**
187     * getVersion method.
188     */
189    #[Override]
190    public function getVersion(ServerInfo $info): string
191    {
192        return $info->gameversion ?? 'unknown';
193    }
194
195    /**
196     * Parse server info line.
197     */
198    private function parseServerInfo(string $info): void
199    {
200        // Format: \key\value\key\value...
201        $parts = explode('\\', $info);
202
203        // Skip empty first part if it starts with \\ (safe check)
204        if (isset($parts[0]) && $parts[0] === '') {
205            array_shift($parts);
206        }
207
208        $rules = [];
209
210        for ($i = 0; $i < count($parts) - 1; $i += 2) {
211            if (isset($parts[$i], $parts[$i + 1])) {
212                $key   = $parts[$i];
213                $value = $parts[$i + 1];
214
215                // Map common keys to properties
216                switch ($key) {
217                    case 'hostname':
218                        $this->servertitle = $value;
219
220                        break;
221
222                    case 'mapname':
223                        $this->mapname = $value;
224
225                        break;
226
227                    case 'maxclients':
228                        $this->maxplayers = (int) $value;
229
230                        break;
231
232                    case 'clients':
233                        $this->numplayers = (int) $value;
234
235                        break;
236
237                    case 'protocol':
238                        // Protocol version
239                        break;
240
241                    case 'version':
242                        $this->gameversion = $value;
243
244                        break;
245
246                    default:
247                        // Store as rule
248                        $rules[$key] = $value;
249
250                        break;
251                }
252            }
253        }
254
255        $this->rules    = $rules;
256        $this->gamename = 'Quake1';
257    }
258
259    /**
260     * Parse player lines.
261     *
262     * @param array<mixed> $playerLines
263     */
264    private function parsePlayers(array $playerLines): void
265    {
266        $players = [];
267
268        foreach ($playerLines as $line) {
269            $line = trim((string) $line);
270
271            if ($line === '') {
272                continue;
273            }
274
275            if ($line === '0') {
276                continue;
277            }
278
279            // Quake1 player format: score ping "name" "skin" team time
280            // Example: 10 50 "PlayerName" "skin" 0 123.45
281            $parts = preg_split('/\s+/', $line, -1, PREG_SPLIT_NO_EMPTY);
282
283            if ($parts === false || count($parts) < 6) {
284                continue;
285            }
286
287            $player = [
288                'score' => (int) $parts[0],
289                /** @phpstan-ignore offsetAccess.notFound */
290                'ping' => (int) $parts[1],
291                /** @phpstan-ignore offsetAccess.notFound */
292                'name' => trim($parts[2], '"'),
293                /** @phpstan-ignore offsetAccess.notFound */
294                'skin' => trim($parts[3], '"'),
295                /** @phpstan-ignore offsetAccess.notFound */
296                'team' => (int) $parts[4],
297                /** @phpstan-ignore offsetAccess.notFound */
298                'time' => (float) $parts[5],
299            ];
300
301            $players[] = $player;
302        }
303
304        $this->players = $players;
305    }
306}