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 array_keys;
16: use function array_merge;
17: use function array_search;
18: use function array_unique;
19: use function array_values;
20: use function basename;
21: use function class_exists;
22: use function count;
23: use function explode;
24: use function file_put_contents;
25: use function glob;
26: use function is_array;
27: use function sort;
28: use function strcasecmp;
29: use function uksort;
30: use function usort;
31: use function var_export;
32: use ReflectionClass;
33:
34: /**
35: * Utility class to generate markdown and HTML documentation of server protocols.
36: */
37: class DocumentProtocols
38: {
39: /** @var array<string, array<string>> */
40: private array $protocols = [];
41:
42: /** @var array<string> */
43: private array $gameList = [];
44:
45: /** @var array<string, array<string>> */
46: private array $series = [];
47: private int $totalProtocols = 0;
48: private int $totalGames = 0;
49:
50: /**
51: * Initializes the documentation generator with the protocols directory.
52: *
53: * @param string $protocolsDir Path to the server protocols directory
54: */
55: public function __construct(private readonly string $protocolsDir = __DIR__ . '/ServerProtocols')
56: {
57: }
58:
59: /**
60: * parseProtocols method.
61: */
62: public function parseProtocols(): void
63: {
64: $files = glob($this->protocolsDir . '/*.php');
65:
66: if ($files === false) {
67: $files = [];
68: }
69:
70: $this->protocols = [];
71: $this->gameList = [];
72:
73: foreach ($files as $file) {
74: $className = basename($file, '.php');
75: $fullClassName = 'Clansuite\\ServerQuery\\ServerProtocols\\' . $className;
76:
77: if (!class_exists($fullClassName)) {
78: continue;
79: }
80:
81: $reflection = new ReflectionClass($fullClassName);
82:
83: if (!$reflection->isInstantiable()) {
84: continue;
85: }
86:
87: /** @var CSQuery $instance */
88: $instance = $reflection->newInstanceWithoutConstructor();
89:
90: $parent = $reflection->getParentClass();
91: $baseProtocol = $parent !== false ? $parent->getName() : 'CSQuery';
92:
93: $name = $instance->name;
94:
95: if ($name === 'Unknown') {
96: var_export($className);
97: }
98: $supportedGames = $instance->supportedGames;
99: $protocol = $instance->protocol;
100: $game_series_list = $instance->game_series_list;
101:
102: $base = $this->getBaseProtocol($protocol);
103:
104: if (!isset($this->protocols[$base])) {
105: $this->protocols[$base] = [];
106: }
107: $this->protocols[$base] = array_merge($this->protocols[$base], $supportedGames);
108: }
109:
110: // Sort bases in custom order
111: $order = ['Steam', 'Half-Life', 'Gamespy', 'Gamespy 2', 'Gamespy 3', 'Quake', 'Quake 3', 'Unreal 2', 'Minecraft', 'Mumble', 'Teamspeak 3', 'SAMP', 'Factorio', 'ASE', 'Assetto Corsa', 'Battlefield', 'Cube Engine', 'DDnet', 'Eco', 'Farming Simulator', 'Palworld', 'SQP', 'Satisfactory', 'Starbound', 'Terraria', 'Tibia', 'Torque', 'Ventrilo', 'Launcher Protocol', 'Tribes 2', 'BeamMP'];
112: uksort($this->protocols, static function (string $a, string $b) use ($order): int
113: {
114: $posA = array_search($a, $order, true);
115: $posB = array_search($b, $order, true);
116:
117: if ($posA === false) {
118: $posA = 999;
119: }
120:
121: if ($posB === false) {
122: $posB = 999;
123: }
124:
125: return $posA <=> $posB;
126: });
127:
128: // Deduplicate and sort games under each base
129: foreach ($this->protocols as $base => &$games) {
130: $games = array_unique($games);
131: sort($games);
132: }
133:
134: // Build gameList
135: $this->gameList = [];
136:
137: foreach ($this->protocols as $base => $games) {
138: foreach ($games as $game) {
139: $this->gameList[] = $game . ' - ' . $base;
140: }
141: }
142:
143: // Sort gameList alphabetically
144: usort($this->gameList, static function (string $a, string $b): int
145: {
146: $gameA = explode(' - ', $a)[0];
147: $gameB = explode(' - ', $b)[0];
148:
149: return strcasecmp($gameA, $gameB);
150: });
151:
152: // Build series
153: $this->series = [];
154:
155: foreach ($files as $file) {
156: $className = basename($file, '.php');
157: $fullClassName = 'Clansuite\\ServerQuery\\ServerProtocols\\' . $className;
158:
159: if (!class_exists($fullClassName)) {
160: continue;
161: }
162:
163: $reflection = new ReflectionClass($fullClassName);
164:
165: if (!$reflection->isInstantiable()) {
166: continue;
167: }
168:
169: /** @var CSQuery $instance */ $instance = $reflection->newInstanceWithoutConstructor();
170: $game_series_list = $instance->game_series_list ?? [$instance->name ?? 'Unknown'];
171: $supportedGames = $instance->supportedGames ?? [];
172:
173: foreach ($game_series_list as $series) {
174: if (!isset($this->series[$series])) {
175: $this->series[$series] = [];
176: }
177: $this->series[$series] = array_merge($this->series[$series], $supportedGames);
178: }
179: }
180:
181: foreach ($this->series as &$games) {
182: $games = array_unique($games);
183: sort($games);
184: }
185:
186: // Set totals
187: $this->totalProtocols = count($this->protocols);
188: $this->totalGames = count($this->gameList);
189: }
190:
191: /**
192: * renderMarkdown method.
193: */
194: public function renderMarkdown(): string
195: {
196: $markdown = "## Clansuite Server Query\n";
197: $markdown .= "### Table of Contents\n\n";
198: $markdown .= "1. [Supported Server Protocols](#supported-server-protocols)\n";
199: $markdown .= "2. [Supported Game and Voice Servers](#supported-game-and-voice-servers)\n";
200: $markdown .= "3. [Game Series](#game-series)\n\n";
201: $markdown .= "\n";
202: $markdown .= "---\n";
203: $markdown .= "\n";
204: $markdown .= "### Supported Server Protocols\n\n";
205: $markdown .= "Total Protocols: {$this->totalProtocols}\n";
206: $markdown .= "Total Games: {$this->totalGames}\n\n";
207: $markdown .= "This section lists the server protocols supported, organized in a hierarchical tree structure.\n";
208: $markdown .= "Each protocol serves as a top-level category, with the games they support listed underneath.\n\n";
209: $markdown .= "```\n";
210: $markdown .= "Server Query Protocols\n";
211: $totalBases = count($this->protocols);
212: $i = 0;
213:
214: foreach ($this->protocols as $base => $games) {
215: $isLastBase = $i === $totalBases - 1;
216: $baseConnector = $isLastBase ? '└── ' : '├── ';
217: $markdown .= $baseConnector . $base . "\n";
218:
219: foreach ($games as $gIndex => $game) {
220: $isLastGame = $gIndex === count($games) - 1;
221: $gameConnector = $isLastBase ? ' ' : '│ ';
222: $markdown .= $gameConnector . $game . "\n";
223: }
224: $i++;
225: }
226: $markdown .= "```\n\n";
227: $markdown .= "### Supported Game and Voice Servers\n\n";
228: $markdown .= "This is a table of all supported games with their corresponding protocols, including voice servers.\n\n";
229: $markdown .= "| Number | Game Name | Server Protocol |\n";
230: $markdown .= "|--------|-----------|-----------------|\n";
231:
232: foreach ($this->gameList as $index => $game) {
233: $parts = explode(' - ', (string) $game, 2);
234: $gameName = $parts[0];
235: $protocol = $parts[1] ?? '';
236: $number = $index + 1;
237: $markdown .= "| {$number} | {$gameName} | {$protocol} |\n";
238: }
239: $markdown .= "\n";
240: $markdown .= "### Game Series\n\n";
241: $markdown .= "This section lists games grouped by their series, regardless of protocol differences.\n\n";
242:
243: foreach ($this->series as $series => $games) {
244: $markdown .= "#### {$series}\n\n";
245:
246: if (is_array($games)) {
247: foreach ($games as $game) {
248: $markdown .= '- ' . (string) $game . "\n";
249: }
250: }
251: $markdown .= "\n";
252: }
253: $markdown .= "\n";
254:
255: return $markdown;
256: }
257:
258: /**
259: * renderHtml method.
260: */
261: public function renderHtml(): string
262: {
263: $html = "<!DOCTYPE html>\n<html>\n";
264: $html .= "<head>\n<title>Supported Server Protocols by Clansuite Server Query</title>\n</head>\n";
265: $html .= "<body>\n";
266: $html .= "<h2>Clansuite Server Query</h2>\n";
267: $html .= "Clansuite Server Query currently supports {$this->totalProtocols} server protocols and is compatible with {$this->totalGames} game and voice servers.\n";
268: $html .= "<p>This document provides a complete list of supported protocols and games.</p>\n";
269: $html .= "<p>If the server you are looking for is not included, you can either contribute by forking the project on <a href='https://github.com/Clansuite/ServerQuery'>GitHub</a> and add it yourself, or <a href='https://github.com/Clansuite/ServerQuery/issues'>submit a request</a> to have it added.</p>\n";
270: $html .= "<p>Best regards,<br>Jens A. Koch</p>\n";
271:
272: $html .= "<h3>Supported Server Protocols</h3>\n";
273: $html .= "Clansuite Server Query currently supports {$this->totalProtocols} server protocols.\n";
274: $html .= '<p>The protocols are displayed in a hierarchical tree structure.</p>';
275: $html .= "<p>Each protocol appears as a top-level category, with the supported games organized beneath it.</p>\n";
276: $html .= "<pre>\n";
277: $html .= "Server Query Protocols\n";
278: $totalBases = count($this->protocols);
279: $i = 0;
280:
281: foreach ($this->protocols as $base => $games) {
282: $isLastBase = $i === $totalBases - 1;
283: $baseConnector = $isLastBase ? '└── ' : '├── ';
284: $html .= $baseConnector . $base . "\n";
285:
286: foreach ($games as $gIndex => $game) {
287: $isLastGame = $gIndex === count($games) - 1;
288: $gameConnector = $isLastBase ? ' ' : '│ ';
289: $html .= $gameConnector . $game . "\n";
290: }
291: $i++;
292: }
293: $html .= "</pre>\n\n";
294: $html .= "<h3>Supported Game Servers</h3>\n";
295: $html .= "<p>The table below lists all supported games along with their corresponding server protocols.</p>\n";
296: $html .= "<p>Each entry includes the game name and the protocol it uses for server communication.</p>\n";
297: $html .= "<table>\n";
298: $html .= "<thead>\n<tr>\n<th>#</th>\n<th>Game Name</th>\n<th>Server Protocol</th>\n</tr>\n</thead>\n";
299: $html .= "<tbody>\n";
300:
301: foreach ($this->gameList as $index => $game) {
302: $parts = explode(' - ', (string) $game, 2);
303: $gameName = $parts[0] ?? '';
304: $protocol = $parts[1] ?? '';
305: $number = $index + 1;
306: $html .= "<tr>\n<td>{$number}</td>\n<td>{$gameName}</td>\n<td>{$protocol}</td>\n</tr>\n";
307: }
308: $html .= "</tbody>\n</table>\n\n";
309: $html .= "<h3>Game Series</h3>\n";
310: $html .= "<p>This section lists games organized by series, independent of their server protocols.</p>\n";
311:
312: foreach ($this->series as $series => $games) {
313: /** @var array<string> $games */
314: $html .= "<h4>{$series}</h4>\n<ul>\n";
315:
316: foreach ($games as $game) {
317: $html .= "<li>{$game}</li>\n";
318: }
319: $html .= "</ul>\n";
320: }
321: $html .= "</body>\n</html>\n";
322:
323: return $html;
324: }
325:
326: /**
327: * writeFiles method.
328: */
329: public function writeFiles(string $outputDir = __DIR__ . '/../../'): void
330: {
331: $markdown = $this->renderMarkdown();
332: $html = $this->renderHtml();
333:
334: file_put_contents($outputDir . '/protocols.md', $markdown);
335: file_put_contents($outputDir . '/protocols.html', $html);
336:
337: print "Generated protocols.md and protocols.html\n";
338: }
339:
340: /**
341: * run method.
342: */
343: public function run(): void
344: {
345: $this->parseProtocols();
346: $this->writeFiles();
347: }
348:
349: /**
350: * getSupportedGames method.
351: *
352: * @return array<string>
353: */
354: public function getSupportedGames(): array
355: {
356: return array_merge(...array_values($this->series));
357: }
358:
359: /**
360: * getGameSeries method.
361: *
362: * @return array<string>
363: */
364: public function getGameSeries(): array
365: {
366: return array_keys($this->series);
367: }
368:
369: private function getBaseProtocol(string $protocol): string
370: {
371: $map = [
372: 'A2S' => 'Steam',
373: 'Halflife' => 'Half-Life',
374: 'Halflife2' => 'Half-Life',
375: 'Gamespy' => 'Gamespy',
376: 'Gamespy2' => 'Gamespy 2',
377: 'Gamespy3' => 'Gamespy 3',
378: 'Quake' => 'Quake',
379: 'Quake3' => 'Quake 3',
380: 'Quake4' => 'Quake',
381: 'doom3' => 'Quake 3',
382: 'etqw' => 'Quake 3',
383: 'fear' => 'Quake 3',
384: 'Unreal2' => 'Unreal 2',
385: 'minecraft' => 'Minecraft',
386: 'mumble' => 'Mumble',
387: 'teamspeak3' => 'Teamspeak 3',
388: 'samp' => 'SAMP',
389: 'Satisfactory' => 'Satisfactory',
390: 'starbound' => 'Starbound',
391: 'terraria' => 'Terraria',
392: 'tibia' => 'Tibia',
393: 'Torque' => 'Torque',
394: 'Tribes2' => 'Tribes 2',
395: 'ventrilo' => 'Ventrilo',
396: 'Skulltag' => 'Launcher Protocol',
397: 'Zandronum' => 'Launcher Protocol',
398: 'AgeOfTime' => 'Tribes 2',
399: 'Blockland' => 'Tribes 2',
400: 'beammp' => 'BeamMP',
401: 'CounterStrike16' => 'Half-Life',
402: 'assettocorsa' => 'Assetto Corsa',
403: 'Battlefield4' => 'Battlefield',
404: 'bc2' => 'Battlefield',
405: 'Frostbite' => 'Battlefield',
406: 'BF4' => 'Battlefield',
407: 'Cube' => 'Cube Engine',
408: 'ddnet' => 'DDnet',
409: 'arma' => 'Gamespy 2',
410: 'halo' => 'Gamespy 2',
411: 'swat4' => 'Gamespy 2',
412: 'Bf2' => 'Gamespy 3',
413: 'ut3' => 'Gamespy 3',
414: 'gamespy3' => 'Gamespy 3',
415: 'et' => 'Quake 3',
416: 'ql' => 'Quake 3',
417: 'StarWarsJK' => 'Quake 3',
418: 'urbanterror' => 'Quake 3',
419: 'wolf' => 'Quake 3',
420: 'killingfloor' => 'Unreal 2',
421: 'ravaged' => 'Unreal 2',
422: 'ro2' => 'Unreal 2',
423: 'ror' => 'Unreal 2',
424: 'sniperelite2' => 'Unreal 2',
425: 'conan' => 'Steam',
426: 'dayz' => 'Steam',
427: 'ffow' => 'Steam',
428: 'source' => 'Steam',
429: 'dods' => 'Steam',
430: 'scum' => 'Steam',
431: 'ASE' => 'ASE',
432: 'gta-san-andreas-mta' => 'ASE',
433: 'SQP' => 'SQP',
434: 'Factorio' => 'Factorio',
435: 'Eco' => 'Eco',
436: 'FarmingSimulator' => 'Farming Simulator',
437: 'Palworld' => 'Palworld',
438: ];
439:
440: return $map[$protocol] ?? $protocol;
441: }
442: }
443:
444: // Run if called directly
445: if (isset($argv) && __FILE__ === $argv[0]) {
446: $doc = new DocumentProtocols;
447: $doc->run();
448: }
449: