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\ServerProtocols;
14:
15: use function count;
16: use function explode;
17: use function htmlentities;
18: use function is_string;
19: use function preg_match;
20: use function preg_replace;
21: use function preg_split;
22: use function sprintf;
23: use function str_repeat;
24: use function strlen;
25: use function strtolower;
26: use function substr;
27: use 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: */
33: class 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: }
307: