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 chr;
16: use function explode;
17: use function fclose;
18: use function file_get_contents;
19: use function fread;
20: use function fsockopen;
21: use function fwrite;
22: use function is_array;
23: use function json_decode;
24: use function pack;
25: use function preg_match;
26: use function str_ends_with;
27: use function str_starts_with;
28: use function stream_context_create;
29: use function strpos;
30: use function substr;
31: use function trim;
32: use Clansuite\Capture\Protocol\ProtocolInterface;
33: use Clansuite\Capture\ServerAddress;
34: use Clansuite\Capture\ServerInfo;
35: use Clansuite\ServerQuery\CSQuery;
36: use Override;
37:
38: /**
39: * Terraria protocol implementation.
40: *
41: * Uses native TCP protocol with TShock REST API fallback.
42: */
43: class 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: }
272: