Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
91.97% covered (success)
91.97%
229 / 249
77.78% covered (warning)
77.78%
7 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
DocumentProtocols
92.68% covered (success)
92.68%
228 / 246
77.78% covered (warning)
77.78%
7 / 9
42.69
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 parseProtocols
77.46% covered (warning)
77.46%
55 / 71
0.00% covered (danger)
0.00%
0 / 1
23.13
 renderMarkdown
100.00% covered (success)
100.00%
48 / 48
100.00% covered (success)
100.00%
1 / 1
9
 renderHtml
100.00% covered (success)
100.00%
48 / 48
100.00% covered (success)
100.00%
1 / 1
8
 writeFiles
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 run
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 getSupportedGames
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getGameSeries
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getBaseProtocol
100.00% covered (success)
100.00%
69 / 69
100.00% covered (success)
100.00%
1 / 1
1
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;
14
15use function array_keys;
16use function array_merge;
17use function array_search;
18use function array_unique;
19use function array_values;
20use function basename;
21use function class_exists;
22use function count;
23use function explode;
24use function file_put_contents;
25use function glob;
26use function is_array;
27use function sort;
28use function strcasecmp;
29use function uksort;
30use function usort;
31use function var_export;
32use ReflectionClass;
33
34/**
35 * Utility class to generate markdown and HTML documentation of server protocols.
36 */
37class 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
445if (isset($argv) && __FILE__ === $argv[0]) {
446    $doc = new DocumentProtocols;
447    $doc->run();
448}