Files
MRCC/launcher/PCL-CE/PCL.Core/Link/McPing/McPingService.cs
T
xyou f70b061d1a
CI / Go Backend (push) Canceled after 0s
feat: 项目初始化 + 3D方块世界原型 + AI助搭系统
初始化 monorepo: Go后端(7微服务) + Unity客户端(9模块) + 启动器

HTML5原型: Three.js 3D体素世界, Perlin噪声地形, 原版材质, 22种方块

Minecraft创造模式背包: 双栏布局, 拖拽移动物品, 方向性元件引脚

AI助搭策划文档 + 客户端/服务端骨架 + Docker Compose + CI
2026-08-08 14:07:56 +08:00

383 lines
15 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Buffers.Binary;
using PCL.Core.App.Localization;
using PCL.Core.Link.McPing.Model;
using PCL.Core.Logging;
using PCL.Core.Utils;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Sockets;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Threading;
using System.Threading.Tasks;
namespace PCL.Core.Link.McPing;
/// <summary>
/// 现代Minecraft协议服务器探测服务实现
/// 支持1.7+版本的服务器信息查询协议
/// </summary>
public class McPingService : IMcPingService
{
private readonly IPEndPoint _endpoint;
private readonly string _host;
private const int DefaultTimeout = 10000;
private readonly int _timeout;
private bool _disposed;
private const string ModuleName = "McPing";
public IPEndPoint Endpoint => _endpoint;
public string Host => _host;
public int Timeout => _timeout;
public McPingService(IPEndPoint endpoint, int timeout = DefaultTimeout)
{
_endpoint = endpoint;
_host = _endpoint.Address.ToString();
_timeout = timeout;
}
public McPingService(string ip, int port = 25565, int timeout = DefaultTimeout)
{
_endpoint = IPAddress.TryParse(ip, out var ipAddress)
? new IPEndPoint(ipAddress, port)
: new IPEndPoint(Dns.GetHostAddresses(ip).First(), port);
_host = ip;
_timeout = timeout;
}
public McPingService(string host, IPEndPoint endpoint, int timeout = DefaultTimeout)
{
_endpoint = endpoint;
_host = host;
_timeout = timeout;
}
/// <summary>
/// 执行现代Minecraft协议的服务器探测
/// </summary>
/// <param name="cancellationToken"></param>
/// <returns></returns>
public async Task<McPingResult?> PingAsync(CancellationToken cancellationToken = default)
{
using var so = new Socket(SocketType.Stream, ProtocolType.Tcp);
using var timeoutCts = new CancellationTokenSource(_timeout);
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeoutCts.Token);
try
{
LogWrapper.Debug(ModuleName, $"Connecting to {_endpoint}");
await so.ConnectAsync(_endpoint.Address, _endpoint.Port, linkedCts.Token);
}
catch (OperationCanceledException)
{
LogWrapper.Error(new TimeoutException(Lang.Text("Tools.ServerQuery.Error.Timeout.Connect")), ModuleName, $"Failed to connect to the {_endpoint}");
return null;
}
catch (Exception e)
{
LogWrapper.Error(e, ModuleName, $"Failed to connect to the {_endpoint}");
return null;
}
LogWrapper.Debug(ModuleName, $"Connection established: {_endpoint}");
await using var stream = new NetworkStream(so, false);
var handshakePacket = _BuildHandshakePacket(_host, _endpoint.Port);
var statusPacket = _BuildStatusRequestPacket();
byte[]? statusPayload;
long latency = 0;
try
{
await stream.WriteAsync(handshakePacket, linkedCts.Token);
LogWrapper.Debug(ModuleName, $"Handshake sent, packet length: {handshakePacket.Length}");
await stream.WriteAsync(statusPacket, linkedCts.Token);
LogWrapper.Debug(ModuleName, $"Status sent, packet length: {statusPacket.Length}");
var pingTimestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
var pingPacket = _BuildPingRequestPacket(pingTimestamp);
await stream.WriteAsync(pingPacket, linkedCts.Token);
LogWrapper.Debug(ModuleName, $"Ping sent, packet length: {pingPacket.Length}");
(statusPayload, latency) = await _ReadStatusPayloadAsync(stream, linkedCts.Token);
}
catch (OperationCanceledException)
{
LogWrapper.Error(new TimeoutException(Lang.Text("Tools.ServerQuery.Error.Timeout.ReadWrite")), "McPing", $"Operation timed out on {_endpoint}");
return null;
}
catch (Exception e)
{
LogWrapper.Error(e, ModuleName, $"Failed to communicate with {_endpoint}: {e.Message}");
return null;
}
finally
{
if (so.Connected) so.Shutdown(SocketShutdown.Both);
}
so.Close();
if (statusPayload is null || statusPayload.Length == 0) throw new InvalidDataException(Lang.Text("Tools.ServerQuery.State.NoInfo"));
var retCtx = Encoding.UTF8.GetString(statusPayload);
var retJson = JsonCompat.ParseNode(retCtx) ?? throw new NullReferenceException(Lang.Text("Tools.ServerQuery.Error.InvalidResponse"));
#if DEBUG
var resJsonDebug = retJson.DeepClone();
if (resJsonDebug is JsonObject jsonObject && jsonObject.ContainsKey("favicon"))
{
jsonObject["favicon"] = "...";
}
LogWrapper.Debug(ModuleName, resJsonDebug.ToJsonString());
#endif
// 先处理Description字段,将其转换为字符串形式
if (retJson["description"] is JsonObject descObj)
{
retJson["description"] = _ConvertJNodeToMcString(descObj);
}
var response = JsonSerializer.Deserialize<McPingResult>(retJson, JsonCompat.SerializerOptions);
if (response?.Version is null)
throw new NullReferenceException(Lang.Text("Tools.ServerQuery.Error.InvalidResponse"));
response = response with
{
Latency = latency
};
return response;
}
public void Dispose()
{
if (_disposed) return;
_disposed = true;
GC.SuppressFinalize(this);
}
/// <summary>
/// 构建握手包
/// </summary>
/// <param name="serverIp">服务器的地址</param>
/// <param name="serverPort">服务器的端口</param>
/// <returns>返回握手包的字节数组</returns>
private byte[] _BuildHandshakePacket(string serverIp, int serverPort)
{
List<byte> handshake = [];
handshake.AddRange(VarIntHelper.Encode(0)); //状态头 表明这是一个握手包
handshake.AddRange(VarIntHelper.Encode(772)); //协议头 表明请求客户端的版本
var binaryIp = Encoding.UTF8.GetBytes(serverIp);
if (binaryIp.Length > 255) throw new Exception(Lang.Text("Tools.ServerQuery.Error.AddressTooLong"));
handshake.AddRange(VarIntHelper.Encode((uint)binaryIp.Length)); //服务器地址长度
handshake.AddRange(binaryIp); //服务器地址
handshake.AddRange(BitConverter.GetBytes((ushort)serverPort).AsEnumerable().Reverse()); //服务器端口
handshake.AddRange(VarIntHelper.Encode(1)); //1 表明当前状态为 ping 2 表明当前的状态为连接
handshake.InsertRange(0, VarIntHelper.Encode((uint)handshake.Count)); //包长度
return handshake.ToArray();
}
private byte[] _BuildStatusRequestPacket()
{
List<byte> statusRequest = [];
statusRequest.AddRange(VarIntHelper.Encode(1)); //包长度
statusRequest.AddRange(VarIntHelper.Encode(0)); //包 ID
return statusRequest.ToArray();
}
private byte[] _BuildPingRequestPacket(long timestamp)
{
List<byte> pingRequest = [];
// Packet ID 使用值为 1 的 VarInt 编码和 8 字节的 long 时间戳
pingRequest.AddRange(VarIntHelper.Encode(9));
pingRequest.AddRange(VarIntHelper.Encode(1));
pingRequest.AddRange(BitConverter.GetBytes(timestamp).AsEnumerable().Reverse());
return pingRequest.ToArray();
}
private async Task<(byte[] StatusPayload, long Latency)> _ReadStatusPayloadAsync(Stream stream, CancellationToken cancellationToken)
{
byte[]? statusPayload = null;
long? latency = null;
try
{
while (statusPayload is null || latency is null)
{
var packetLength = checked((int)await VarIntHelper.ReadFromStreamAsync(stream, cancellationToken));
LogWrapper.Debug(ModuleName, $"Packet length: {packetLength}");
if (packetLength <= 0) throw new InvalidDataException(Lang.Text("Tools.ServerQuery.Error.EmptyPacket"));
var packetData = await _ReadExactAsync(stream, packetLength, cancellationToken);
using var packetStream = new MemoryStream(packetData, writable: false);
var packetId = checked((int)await VarIntHelper.ReadFromStreamAsync(packetStream, cancellationToken));
LogWrapper.Debug(ModuleName, $"Packet id: {packetId}");
switch (packetId)
{
case 0:
var jsonLength = checked((int)await VarIntHelper.ReadFromStreamAsync(packetStream, cancellationToken));
statusPayload = await _ReadExactAsync(packetStream, jsonLength, cancellationToken);
if (packetStream.Position != packetStream.Length)
LogWrapper.Warn(ModuleName, $"Status packet contains {packetStream.Length - packetStream.Position} trailing bytes.");
break;
case 1:
var pongData = await _ReadExactAsync(packetStream, 8, cancellationToken);
if (packetStream.Position != packetStream.Length)
LogWrapper.Warn(ModuleName, $"Pong packet contains {packetStream.Length - packetStream.Position} trailing bytes.");
latency = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() - _ReadInt64BigEndian(pongData);
break;
default:
LogWrapper.Warn(ModuleName, $"Ignore unexpected packet type: {packetId}");
break;
}
}
}
catch (EndOfStreamException ex)
{
if (statusPayload is not null && latency is null)
throw new EndOfStreamException(Lang.Text("Tools.ServerQuery.Error.StaleConnection"), ex);
if (statusPayload is null)
throw new EndOfStreamException(Lang.Text("Tools.ServerQuery.Error.IncompleteConnection"), ex);
throw;
}
return (statusPayload, latency.Value);
}
private static long _ReadInt64BigEndian(byte[] data)
{
return data.Length != 8
? throw new ArgumentException(Lang.Text("Tools.ServerQuery.Error.PongDataLength"), nameof(data))
: BinaryPrimitives.ReadInt64BigEndian(data);
}
private static async Task<byte[]> _ReadExactAsync(Stream stream, int length, CancellationToken cancellationToken)
{
var buffer = new byte[length];
await stream.ReadExactlyAsync(buffer, cancellationToken);
return buffer;
}
private static string _ConvertJNodeToMcString(JsonNode? jsonNode)
{
if (jsonNode is null) return string.Empty;
StringBuilder result = new();
Stack<JsonNode> stack = new();
stack.Push(jsonNode);
while (stack.Count > 0)
{
var current = stack.Pop();
switch (current.GetValueKind())
{
// 处理对象
case JsonValueKind.Object:
{
var obj = current.AsObject();
// LogWrapper.Debug("McPing",$"Treat {obj} as JObject");
// 检查并处理 extra 数组
if (obj.TryGetPropertyValue("extra", out var extraNode) && extraNode is JsonArray extraArray)
// 逆序压栈保证原始顺序
for (var i = extraArray.Count - 1; i >= 0; i--)
if (extraArray[i] is not null)
stack.Push(extraArray[i]!);
// 检查并处理 text 属性
if (obj.TryGetPropertyValue("text", out _))
{
var formatCode = _GetTextStyleString(
obj["color"]?.ToString() ?? string.Empty,
Convert.ToBoolean(obj["bold"]?.ToString() ?? "false"),
Convert.ToBoolean(obj["obfuscated"]?.ToString() ?? "false"),
Convert.ToBoolean(obj["strikethrough"]?.ToString() ?? "false"),
Convert.ToBoolean(obj["underline"]?.ToString() ?? "false"),
Convert.ToBoolean(obj["italic"]?.ToString() ?? "false")
);
result.Append($"{formatCode}{obj["text"] ?? string.Empty}");
}
break;
}
// 处理字符串值
case JsonValueKind.String:
{
// LogWrapper.Debug("McPing",$"Treat {value} as JValue");
result.Append(current);
break;
}
// 处理数组
// 逆序压栈保证原始顺序
case JsonValueKind.Array:
{
var jArr = current.AsArray();
// LogWrapper.Debug("McPing",$"Treat {array} as JArray");
for (var i = jArr.Count - 1; i >= 0; i--)
if (jArr[i] is not null)
stack.Push(jArr[i]!);
break;
}
default:
{
LogWrapper.Warn(ModuleName, $"解析到无法处理的 Motd 内容({current.GetValueKind()}){current}");
break;
}
}
}
LogWrapper.Debug(ModuleName, $"处理 Motd 内容完成,结果:{result}");
return result.ToString();
}
private static readonly Dictionary<string, string> _ColorMap = new()
{
["black"] = "0",
["dark_blue"] = "1",
["dark_green"] = "2",
["dark_aqua"] = "3",
["dark_red"] = "4",
["dark_purple"] = "5",
["gold"] = "6",
["gray"] = "7",
["dark_gray"] = "8",
["blue"] = "9",
["green"] = "a",
["aqua"] = "b",
["red"] = "c",
["light_purple"] = "d",
["yellow"] = "e",
["white"] = "f"
};
private static string _GetTextStyleString(
string color,
bool bold = false,
bool obfuscated = false,
bool strikethrough = false,
bool underline = false,
bool italic = false)
{
var sb = new StringBuilder();
if (_ColorMap.TryGetValue(color, out var colorCode)) sb.Append($"§{colorCode}");
if (bold) sb.Append("§l");
if (italic) sb.Append("§o");
// if (obfuscated) sb.Append("§k"); // 暂时别用
if (underline) sb.Append("§n");
if (strikethrough) sb.Append("§m");
if (color.StartsWith('#')) sb.Append(color);
return sb.ToString();
}
}