Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 92
0.00% covered (danger)
0.00%
0 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
Terraria
0.00% covered (danger)
0.00%
0 / 92
0.00% covered (danger)
0.00%
0 / 10
992
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 query
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 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
 query_server
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
12
 queryTShockAPI
0.00% covered (danger)
0.00%
0 / 26
0.00% covered (danger)
0.00%
0 / 1
90
 parseTShockResponse
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
30
 queryNativeTCP
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
12
 parseNativeResponse
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
30
 openSocket
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
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\ServerProtocols;
14
15use function chr;
16use function explode;
17use function fclose;
18use function file_get_contents;
19use function fread;
20use function fsockopen;
21use function fwrite;
22use function is_array;
23use function json_decode;
24use function pack;
25use function preg_match;
26use function str_ends_with;
27use function str_starts_with;
28use function stream_context_create;
29use function strpos;
30use function substr;
31use function trim;
32use Clansuite\Capture\Protocol\ProtocolInterface;
33use Clansuite\Capture\ServerAddress;
34use Clansuite\Capture\ServerInfo;
35use Clansuite\ServerQuery\CSQuery;
36use Override;
37
38/**
39 * Terraria protocol implementation.
40 *
41 * Uses native TCP protocol with TShock REST API fallback.
42 */
43class Terraria extends CSQuery implements ProtocolInterface
44{
45    /**
46     * Protocol name.
47     */
48    public string $name = 'Terraria';
49
50    /**
51     * List of supported games.
52     *
53     * @var array<string>
54     */
55    public array $supportedGames = ['Terraria'];
56
57    /**
58     * Protocol identifier.
59     */
60    public string $protocol = 'terraria';
61
62    /**
63     * Constructor.
64     */
65    public function __construct(?string $address = null, ?int $queryport = null)
66    {
67        parent::__construct();
68        $this->address   = $address;
69        $this->queryport = $queryport;
70    }
71
72    /**
73     * query method.
74     */
75    #[Override]
76    public function query(ServerAddress $addr): ServerInfo
77    {
78        // Implement TCP query
79        $info         = new ServerInfo;
80        $info->online = false;
81
82        // Try TShock API first
83        if ($this->queryTShockAPI($addr, $info)) {
84            return $info;
85        }
86
87        // Fallback to native TCP protocol
88        return $this->queryNativeTCP($addr, $info);
89    }
90
91    /**
92     * getProtocolName method.
93     */
94    #[Override]
95    public function getProtocolName(): string
96    {
97        return $this->protocol;
98    }
99
100    /**
101     * getVersion method.
102     */
103    #[Override]
104    public function getVersion(ServerInfo $info): string
105    {
106        return $info->version ?? 'unknown';
107    }
108
109    /**
110     * query_server method.
111     */
112    #[Override]
113    public function query_server(bool $getPlayers = true, bool $getRules = true): bool
114    {
115        // Use TCP query
116        $addr = new ServerAddress($this->address ?? '', $this->queryport ?? 0);
117        $info = $this->query($addr);
118
119        $this->online      = $info->online;
120        $this->servertitle = $info->name ?? '';
121        $this->mapname     = $info->map ?? '';
122        $this->numplayers  = $info->players_current ?? 0;
123        $this->maxplayers  = $info->players_max ?? 0;
124        $this->players     = [];
125
126        foreach ($info->players as $player) {
127            if (is_array($player)) {
128                $this->players[] = [
129                    'name'  => (string) ($player['name'] ?? ''),
130                    'score' => (int) ($player['score'] ?? 0),
131                    'time'  => (int) ($player['time'] ?? 0),
132                ];
133            }
134        }
135
136        return $this->online;
137    }
138
139    private function queryTShockAPI(ServerAddress $addr, ServerInfo $info): bool
140    {
141        // TShock REST API on port 7878 (game port + 101)
142        $host      = $addr->ip;
143        $apiPort   = $addr->port + 101;
144        $endpoints = [
145            "http://{$host}:{$apiPort}/v2/server/status",
146            "http://{$host}:{$apiPort}/status",
147            "http://{$host}:{$apiPort}/v3/server/status",
148        ];
149
150        foreach ($endpoints as $url) {
151            $context = stream_context_create([
152                'http' => [
153                    'timeout'    => 5,
154                    'user_agent' => 'Clansuite-Query/1.0',
155                ],
156            ]);
157
158            $response = @file_get_contents($url, false, $context);
159
160            if ($response !== false) {
161                // Check if it's HTTP 200 and ends with }
162                if (str_starts_with($response, 'HTTP/1.1 200') && str_ends_with(trim($response), '}')) {
163                    $jsonStart = strpos($response, '{');
164
165                    if ($jsonStart !== false) {
166                        $jsonBody = substr($response, $jsonStart);
167                        $data     = json_decode($jsonBody, true);
168
169                        if ($data !== null && isset($data['status']) && $data['status'] === '200') {
170                            $this->parseTShockResponse($data, $info);
171                            $info->online = true;
172
173                            return true;
174                        }
175                    }
176                }
177            }
178        }
179
180        return false;
181    }
182
183    /**
184     * @param array<mixed> $data
185     */
186    private function parseTShockResponse(array $data, ServerInfo $info): void
187    {
188        $server = $data['server'] ?? null;
189
190        if (is_array($server)) {
191            $info->name            = (string) ($server['name'] ?? '');
192            $info->map             = (string) ($server['world'] ?? '');
193            $info->players_current = (int) ($server['players'] ?? 0);
194            $info->players_max     = (int) ($server['maxplayers'] ?? 0);
195            $info->version         = (string) ($server['version'] ?? '');
196        }
197
198        $playersData = $data['players'] ?? null;
199
200        if (is_array($playersData)) {
201            $info->players = [];
202
203            foreach ($playersData as $player) {
204                if (is_array($player)) {
205                    $info->players[] = [
206                        'name'  => (string) ($player['name'] ?? ''),
207                        'score' => (int) ($player['score'] ?? 0),
208                        'time'  => (int) ($player['time'] ?? 0),
209                    ];
210                }
211            }
212        }
213    }
214
215    private function queryNativeTCP(ServerAddress $addr, ServerInfo $info): ServerInfo
216    {
217        $socket = $this->openSocket($addr->ip, $addr->port, 5);
218
219        if ($socket === false) {
220            return $info;
221        }
222
223        // Send server info request packet
224        $packet = pack('V', 5) . chr(1); // Length 5, type 1
225        fwrite($socket, $packet);
226
227        // Read response
228        $response = fread($socket, 4096);
229        fclose($socket);
230
231        if ($response !== false) {
232            $this->parseNativeResponse($response, $info);
233            $info->online = true;
234        }
235
236        return $info;
237    }
238
239    private function parseNativeResponse(string $response, ServerInfo $info): void
240    {
241        // Terraria native protocol parsing
242        // This is simplified; full implementation would need proper packet parsing
243        $lines = explode("\n", trim($response));
244
245        foreach ($lines as $line) {
246            if (str_starts_with($line, 'Server name: ')) {
247                $info->name = substr($line, 12);
248            } elseif (str_starts_with($line, 'Map: ')) {
249                $info->map = substr($line, 5);
250            } elseif (preg_match('/Players: (\d+)\/(\d+)/', $line, $matches) !== false) {
251                $info->players_current = (int) ($matches[1] ?? 0);
252                $info->players_max     = (int) ($matches[2] ?? 0);
253            }
254        }
255    }
256
257    /**
258     * Open a socket while keeping errno/errstr as variables (avoids passing expressions by reference).
259     *
260     * @return false|resource
261     *
262     * @phpstan-ignore typeCoverage.returnTypeCoverage
263     */
264    private function openSocket(string $host, int $port, int $timeout)
265    {
266        $tmpErrno  = 0;
267        $tmpErrstr = '';
268
269        return @fsockopen($host, $port, $tmpErrno, $tmpErrstr, $timeout);
270    }
271}