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:
19: namespace Clansuite\ServerQuery\ServerProtocols;
20:
21: use const PREG_SPLIT_NO_EMPTY;
22: use function array_shift;
23: use function array_slice;
24: use function count;
25: use function explode;
26: use function preg_split;
27: use function trim;
28: use Clansuite\Capture\Protocol\ProtocolInterface;
29: use Clansuite\Capture\ServerAddress;
30: use Clansuite\Capture\ServerInfo;
31: use Clansuite\ServerQuery\CSQuery;
32: use 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: */
38: class 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: }
307: