feat: 项目初始化 + 3D方块世界原型 + AI助搭系统
CI / Go Backend (push) Canceled after 0s

初始化 monorepo: Go后端(7微服务) + Unity客户端(9模块) + 启动器

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

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

AI助搭策划文档 + 客户端/服务端骨架 + Docker Compose + CI
This commit is contained in:
xyou
2026-08-08 14:07:56 +08:00
parent 9500c4c80a
commit f70b061d1a
1972 changed files with 159760 additions and 6 deletions
@@ -0,0 +1,25 @@
using System;
using System.Buffers;
namespace PCL.Core.Link.Scaffolding.Client.Abstractions;
public interface IRequest<out TResponse>
{
/// <summary>
/// Gets the request type string, e.g., "c:ping".
/// </summary>
string RequestType { get; }
/// <summary>
/// Writes the request body to the provided buffer writer.
/// </summary>
/// <param name="writer">The buffer to write to.</param>
void WriteRequestBody(IBufferWriter<byte> writer);
/// <summary>
/// Parses the response body from the given memory span.
/// </summary>
/// <param name="responseBody">The raw response body.</param>
/// <returns>The parsed response object.</returns>
TResponse ParseResponseBody(ReadOnlyMemory<byte> responseBody);
}
@@ -0,0 +1,5 @@
using System;
namespace PCL.Core.Link.Scaffolding.Client.Abstractions;
public record ScaffoldingResponse(byte Status, ReadOnlyMemory<byte> Body);
@@ -0,0 +1,89 @@
using System;
using System.Buffers;
using System.Buffers.Binary;
using System.IO.Pipelines;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using PCL.Core.Link.Scaffolding.Client.Abstractions;
using PCL.Core.Link.Scaffolding.Exceptions;
namespace PCL.Core.Link.Scaffolding.Client.Framing;
internal static class ProtocolReader
{
/// <summary>
/// Read a response from the pipe.
/// </summary>
/// <param name="reader">The PipeReader to read from.</param>
/// <param name="ct">The CancellationToken.</param>
/// <returns>A task representing the asynchronous operation, with a ScaffoldingResponse as the result.</returns>
/// <exception cref="ScaffoldingRequestException">Thrown if the request fails due to an invalid status.</exception>
/// <exception cref="InvalidOperationException">Thrown if the connection is closed unexpectedly.</exception>
public static async ValueTask<ScaffoldingResponse> ReadResponseAsync(
PipeReader reader,
CancellationToken ct = default)
{
while (true)
{
var result = await reader.ReadAsync(ct).ConfigureAwait(false);
var buffer = result.Buffer;
if (_TryParseResponse(ref buffer, out var response))
{
reader.AdvanceTo(buffer.Start);
return response;
}
reader.AdvanceTo(result.Buffer.Start, result.Buffer.End);
if (result.IsCompleted)
{
throw new InvalidOperationException("Connection closed unexpectedly.");
}
}
}
/// <summary>
/// Tries to parse a response from the given buffer.
/// </summary>
/// <param name="buffer">The buffer containing the response data.</param>
/// <param name="response">The parsed ScaffoldingResponse.</param>
/// <returns>True if the response was successfully parsed; otherwise, false.</returns>
/// <exception cref="ScaffoldingRequestException">Thrown if the request fails due to an invalid status.</exception>
private static bool _TryParseResponse(ref ReadOnlySequence<byte> buffer, out ScaffoldingResponse response)
{
response = null!;
if (buffer.Length < 5)
{
return false;
}
Span<byte> header = stackalloc byte[5];
buffer.Slice(0, 5).CopyTo(header);
var status = header[0];
var bodyLength = BinaryPrimitives.ReadUInt32BigEndian(header[1..]);
var fullPacketLength = 5 + bodyLength;
if (buffer.Length < fullPacketLength)
{
return false;
}
var bodyBuffer = buffer.Slice(5, bodyLength);
var body = bodyBuffer.ToArray();
buffer = buffer.Slice(fullPacketLength);
response = new ScaffoldingResponse(status, body);
if (response.Status != 0)
{
var serverMessage = response.Status == 255 ? Encoding.UTF8.GetString(response.Body.Span) : null;
throw new ScaffoldingRequestException(response.Status, serverMessage);
}
return true;
}
}
@@ -0,0 +1,55 @@
using System;
using System.Buffers;
using System.Buffers.Binary;
using System.IO.Pipelines;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using PCL.Core.Link.Scaffolding.Client.Abstractions;
namespace PCL.Core.Link.Scaffolding.Client.Framing;
internal static class ProtocolWriter
{
/// <summary>
/// Write message to server.
/// </summary>
/// <typeparam name="T">Request body object type.</typeparam>
/// <exception cref="InvalidOperationException">Thrown if request type is too long.</exception>
/// <exception cref="OperationCanceledException">Thrown if operation is canceled.</exception>
public static async ValueTask WriteRequestAsync<T>(
PipeWriter writer,
IRequest<T> request,
CancellationToken ct = default)
{
var bodyWriter = new ArrayBufferWriter<byte>();
request.WriteRequestBody(bodyWriter);
var requestBody = bodyWriter.WrittenMemory;
var requestTypeBytes = Encoding.ASCII.GetBytes(request.RequestType);
if (requestTypeBytes.Length > byte.MaxValue)
{
throw new InvalidOperationException("Request type is too long.");
}
writer.GetSpan(1)[0] = (byte)requestTypeBytes.Length;
writer.Advance(1);
writer.Write(requestTypeBytes);
var lengthSpan = writer.GetSpan(4);
BinaryPrimitives.WriteUInt32BigEndian(lengthSpan, (uint)requestBody.Length);
writer.Advance(4);
if (!requestBody.IsEmpty)
{
writer.Write(requestBody.Span);
}
var result = await writer.FlushAsync(ct).ConfigureAwait(false);
if (result.IsCanceled)
{
throw new OperationCanceledException();
}
}
}
@@ -0,0 +1,3 @@
namespace PCL.Core.Link.Scaffolding.Client.Models;
public record LobbyInfo(string FullCode, string NetworkName, string NetworkSecret);
@@ -0,0 +1,9 @@
namespace PCL.Core.Link.Scaffolding.Client.Models;
public enum LobbyType
{
// ReSharper disable once InconsistentNaming
PCLCE,
Terracotta,
Scaffolding
}
@@ -0,0 +1,12 @@
using System.Text.Json.Serialization;
// ReSharper disable InconsistentNaming
namespace PCL.Core.Link.Scaffolding.Client.Models;
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum PlayerKind
{
HOST,
GUEST
}
@@ -0,0 +1,14 @@
using System.Text.Json.Serialization;
namespace PCL.Core.Link.Scaffolding.Client.Models;
public record PlayerProfile
{
[JsonPropertyName("name")] public required string Name { get; init; }
[JsonPropertyName("machine_id")] public required string MachineId { get; init; }
[JsonPropertyName("vendor")] public required string Vendor { get; init; }
[JsonPropertyName("kind")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public PlayerKind? Kind { get; init; }
}
@@ -0,0 +1,35 @@
using System;
using System.Buffers;
using System.Collections.Generic;
using System.Text.Json;
using System.Text.Json.Serialization;
using PCL.Core.Link.Scaffolding.Client.Abstractions;
using PCL.Core.Link.Scaffolding.Client.Models;
using PCL.Core.Utils;
namespace PCL.Core.Link.Scaffolding.Client.Requests;
public sealed class GetPlayerProfileListRequest : IRequest<IReadOnlyList<PlayerProfile>>
{
private static readonly JsonSerializerOptions _JsonOptions = new(JsonCompat.SerializerOptions)
{
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
/// <inheritdoc />
public string RequestType { get; } = "c:player_profiles_list";
/// <inheritdoc />
public void WriteRequestBody(IBufferWriter<byte> writer)
{
// empty request body
}
/// <inheritdoc />
public IReadOnlyList<PlayerProfile> ParseResponseBody(ReadOnlyMemory<byte> responseBody)
{
var profiles = JsonSerializer.Deserialize<IReadOnlyList<PlayerProfile>>(responseBody.Span, _JsonOptions);
return profiles ?? ArraySegment<PlayerProfile>.Empty;
}
}
@@ -0,0 +1,37 @@
using System;
using System.Buffers;
using System.Collections.Generic;
using System.Text;
using PCL.Core.Link.Scaffolding.Client.Abstractions;
namespace PCL.Core.Link.Scaffolding.Client.Requests;
public sealed class GetProtocolsRequest(IEnumerable<string> supportProtocols) : IRequest<IReadOnlyList<string>>
{
private static readonly byte[] _Separator = [(byte)'\0'];
private readonly IEnumerable<string> _supportProtocols = supportProtocols;
/// <inheritdoc />
public string RequestType { get; } = "c:protocols";
/// <inheritdoc />
public void WriteRequestBody(IBufferWriter<byte> writer)
{
var protocolStr = string.Join('\0', _supportProtocols);
var bytes = Encoding.ASCII.GetBytes(protocolStr);
writer.Write(bytes);
}
/// <inheritdoc />
public IReadOnlyList<string> ParseResponseBody(ReadOnlyMemory<byte> responseBody)
{
if (responseBody.IsEmpty)
{
return ArraySegment<string>.Empty;
}
var responseStr = Encoding.ASCII.GetString(responseBody.Span);
return responseStr.Split('\0', StringSplitOptions.RemoveEmptyEntries);
}
}
@@ -0,0 +1,29 @@
using System;
using System.Buffers;
using System.Buffers.Binary;
using PCL.Core.Link.Scaffolding.Client.Abstractions;
namespace PCL.Core.Link.Scaffolding.Client.Requests;
public sealed class GetServerPortRequest : IRequest<ushort>
{
/// <inheritdoc />
public string RequestType { get; } = "c:server_port";
/// <inheritdoc />
public void WriteRequestBody(IBufferWriter<byte> writer)
{
// empty
}
/// <inheritdoc />
public ushort ParseResponseBody(ReadOnlyMemory<byte> responseBody)
{
if (responseBody.Length != 2)
{
throw new InvalidOperationException("Invalid response body for server port.");
}
return BinaryPrimitives.ReadUInt16BigEndian(responseBody.Span);
}
}
@@ -0,0 +1,35 @@
using System;
using System.Buffers;
using PCL.Core.Link.Scaffolding.Client.Abstractions;
namespace PCL.Core.Link.Scaffolding.Client.Requests;
public sealed class PingRequest : IRequest<ReadOnlyMemory<byte>>
{
private readonly ReadOnlyMemory<byte> _payload;
/// <inheritdoc />
public string RequestType { get; } = "c:ping";
public PingRequest(ReadOnlyMemory<byte> payload)
{
if (payload.Length >= 32)
{
throw new ArgumentException("Payload must be less than 32 bytes.", nameof(payload));
}
_payload = payload;
}
/// <inheritdoc />
public void WriteRequestBody(IBufferWriter<byte> writer)
{
writer.Write(_payload.Span);
}
/// <inheritdoc />
public ReadOnlyMemory<byte> ParseResponseBody(ReadOnlyMemory<byte> responseBody)
{
return responseBody;
}
}
@@ -0,0 +1,43 @@
using System;
using System.Buffers;
using System.Text.Json;
using System.Text.Json.Serialization;
using PCL.Core.Link.Scaffolding.Client.Abstractions;
using PCL.Core.Link.Scaffolding.Client.Models;
using PCL.Core.Utils;
namespace PCL.Core.Link.Scaffolding.Client.Requests;
public sealed class PlayerPingRequest(string name, string machineId, string vendor) : IRequest<bool>
{
private readonly PlayerProfile _profile = new()
{
Name = name,
MachineId = machineId,
Vendor = vendor
};
private static readonly JsonSerializerOptions _JsonOptions = new(JsonCompat.SerializerOptions)
{
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
/// <inheritdoc />
public string RequestType { get; } = "c:player_ping";
/// <inheritdoc />
public void WriteRequestBody(IBufferWriter<byte> writer)
{
using var jsonWriter = new Utf8JsonWriter(writer);
JsonSerializer.Serialize(jsonWriter, _profile, _JsonOptions);
}
/// <inheritdoc />
public bool ParseResponseBody(ReadOnlyMemory<byte> responseBody)
{
// An empty response body indicates success.
return responseBody.IsEmpty;
}
}
@@ -0,0 +1,223 @@
using PCL.Core.Link.Scaffolding.Client.Abstractions;
using PCL.Core.Link.Scaffolding.Client.Framing;
using PCL.Core.Link.Scaffolding.Client.Models;
using PCL.Core.Link.Scaffolding.Client.Requests;
using PCL.Core.Logging;
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO.Pipelines;
using System.Net.Sockets;
using System.Threading;
using System.Threading.Tasks;
namespace PCL.Core.Link.Scaffolding.Client;
internal enum ClientState
{
Disconnected,
Connecting,
Handshaking, // 正在进行握手
Connected, // 握手成功,准备就绪
Disposing
}
/// <summary>
/// A client for the Scaffolding data exchange protocol.
/// </summary>
public sealed class ScaffoldingClient(string host, int scfPort, string playerName, string machineId, string vendor)
: IAsyncDisposable
{
private readonly SemaphoreSlim _srLock = new(1, 1);
private TcpClient? _tcpClient;
private PipeReader? _pipeReader;
private PipeWriter? _pipeWriter;
// Heart Beat
private Task? _heartbeatTask;
private readonly PlayerPingRequest _playerPingRequest = new(playerName, machineId, vendor);
private CancellationTokenSource? _heartbeatCts;
private readonly Stopwatch _heartbeatTimer = new();
private ClientState _state = ClientState.Disconnected;
#region Events
/// <summary>
/// Occurs when a heartbeat signal is received, providing the current list of player profiles and the elapsed time
/// since the last heartbeat.
/// </summary>
/// <remarks>
/// Subscribers can use this event to monitor player activity or synchronize state at regular
/// intervals. The event provides a read-only list of player profiles and an integer representing the elapsed time,
/// typically in milliseconds or seconds, depending on the implementation.
/// </remarks>
public event Action<IReadOnlyList<PlayerProfile>, long>? Heartbeat;
/// <summary>
/// Occurs when the server has been shut down or is unreachable.
/// </summary>
public event Action? ServerShuttedDown;
#endregion
public IReadOnlyList<PlayerProfile>? PlayerList;
public bool IsConnected => _state == ClientState.Connected;
/// <summary>
/// Connects to a Scaffolding server.
/// </summary>
/// <exception cref="Exception">Throws if fialed to connect to server.</exception>
public async Task ConnectAsync(CancellationToken ct = default)
{
if (_state is not ClientState.Disconnected)
{
return;
}
_tcpClient = new TcpClient();
try
{
_state = ClientState.Connecting;
LogWrapper.Info("Scaffolding", $"Trying to connect to server: {host}:{scfPort}");
await _tcpClient.ConnectAsync(host, scfPort, ct).ConfigureAwait(false);
var stream = _tcpClient.GetStream();
_pipeReader = PipeReader.Create(stream);
_pipeWriter = PipeWriter.Create(stream);
_state = ClientState.Handshaking;
LogWrapper.Info("Scaffolding", "Connecting established. Performing handshake...");
await SendRequestAsync(_playerPingRequest, ct).ConfigureAwait(false);
_state = ClientState.Connected;
_StartHeartbeats();
}
catch (Exception ex)
{
LogWrapper.Error(ex, "ScaffoldingClient", "Failed to connect to server.");
await DisposeAsync().ConfigureAwait(false);
ServerShuttedDown?.Invoke();
throw;
}
}
private void _StartHeartbeats()
{
_heartbeatCts = new CancellationTokenSource();
_heartbeatTask = _HeartbeatLoopAsync(_heartbeatCts.Token);
}
private async Task _HeartbeatLoopAsync(CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
try
{
await Task.Delay(TimeSpan.FromSeconds(5), ct).ConfigureAwait(false);
_heartbeatTimer.Start();
await SendRequestAsync(_playerPingRequest, ct).ConfigureAwait(false);
_heartbeatTimer.Stop();
var letancy = _heartbeatTimer.ElapsedMilliseconds;
_heartbeatTimer.Reset();
PlayerList = await SendRequestAsync(new GetPlayerProfileListRequest(), ct).ConfigureAwait(false);
Heartbeat?.Invoke(PlayerList, letancy);
}
catch (OperationCanceledException)
{
break;
}
catch (Exception ex)
{
LogWrapper.Error(ex, "[ScaffoldingClient]",
"Failed when sending heartbeat message. Maybe the server has been shut down.");
ServerShuttedDown?.Invoke();
break;
}
}
}
/// <summary>
/// Send TCP request to the Scaffolding server.
/// </summary>
/// <param name="request">Thr request type.</param>
/// <param name="ct">Cancellation token.</param>
/// <typeparam name="TResponse">Response type.</typeparam>
/// <exception cref="InvalidOperationException">Throws when server is not ready.</exception>
public async Task<TResponse> SendRequestAsync<TResponse>(
IRequest<TResponse> request,
CancellationToken ct = default)
{
if (_state < ClientState.Handshaking)
{
throw new InvalidOperationException("Client is not connected.");
}
if (_pipeWriter is null || _pipeReader is null)
{
throw new InvalidOperationException("Client is not connected.");
}
await _srLock.WaitAsync(ct).ConfigureAwait(false);
try
{
await ProtocolWriter.WriteRequestAsync(_pipeWriter, request, ct).ConfigureAwait(false);
var response = await ProtocolReader.ReadResponseAsync(_pipeReader, ct).ConfigureAwait(false);
return request.ParseResponseBody(response.Body);
}
finally
{
_srLock.Release();
}
}
/// <inheritdoc />
public async ValueTask DisposeAsync()
{
if (_state is ClientState.Disposing)
{
return;
}
_state = ClientState.Disposing;
await CastAndDispose(_srLock).ConfigureAwait(false);
if (_tcpClient is not null) await CastAndDispose(_tcpClient).ConfigureAwait(false);
if (_heartbeatCts is not null)
{
await _heartbeatCts.CancelAsync().ConfigureAwait(false);
await CastAndDispose(_heartbeatCts).ConfigureAwait(false);
}
if (_heartbeatTask is not null)
{
await _heartbeatTask.ConfigureAwait(false);
await CastAndDispose(_heartbeatTask).ConfigureAwait(false);
}
return;
static async ValueTask CastAndDispose(IDisposable resource)
{
if (resource is IAsyncDisposable resourceAsyncDisposable)
await resourceAsyncDisposable.DisposeAsync().ConfigureAwait(false);
else
resource.Dispose();
}
}
}
@@ -0,0 +1,111 @@
using System;
using System.Diagnostics;
using System.IO.Pipelines;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;
using PCL.Core.App.Localization;
using PCL.Core.Logging;
using PCL.Core.Utils;
namespace PCL.Core.Link.Scaffolding.EasyTier;
public class CliNetTest
{
public enum NatType
{
Unknown,
OpenInternet,
NoPat,
FullCone,
Restricted,
PortRestricted,
SymmetricEasy,
Symmetric,
SymmetricFirewall,
UdpBlocked
}
public record NetStatus
{
public required NatType UdpNatType;
public required NatType TcpNatType;
public required bool SupportIPv6;
}
public async static Task<NetStatus?> GetNetStatusAsync()
{
using var cliProcess = new Process();
cliProcess.StartInfo = new ProcessStartInfo
{
FileName = $"{EasyTierMetadata.EasyTierFilePath}\\easytier-cli.exe",
WorkingDirectory = EasyTierMetadata.EasyTierFilePath,
Arguments = $"-o json stun",
ErrorDialog = false,
CreateNoWindow = true,
WindowStyle = ProcessWindowStyle.Hidden,
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
RedirectStandardInput = true,
StandardOutputEncoding = Encoding.UTF8,
StandardErrorEncoding = Encoding.UTF8,
StandardInputEncoding = Encoding.UTF8
};
cliProcess.EnableRaisingEvents = true;
cliProcess.Start();
var reader = PipeReader.Create(cliProcess.StandardOutput.BaseStream);
StunInfo? stunInfo = null;
try
{
stunInfo = await JsonSerializer.DeserializeAsync<StunInfo>(reader, JsonCompat.SerializerOptions);
}
catch (Exception ex)
{
LogWrapper.Warn(ex, "Link", "Failed to do net test");
}
if (stunInfo is null) return null;
var supportIPv6 = false;
foreach (var ip in stunInfo.Ips)
{
if (ip.Contains(":"))
{
supportIPv6 = true;
break;
}
}
return new NetStatus { UdpNatType = GetNatTypeViaCode(stunInfo.UdpNatType), TcpNatType = GetNatTypeViaCode(stunInfo.TcpNatType), SupportIPv6 = supportIPv6 };
}
public static NatType GetNatTypeViaCode(int type) => type switch
{
0 => NatType.OpenInternet,
1 => NatType.NoPat,
2 => NatType.FullCone,
3 => NatType.Restricted,
4 => NatType.PortRestricted,
5 => NatType.SymmetricEasy,
6 => NatType.Symmetric,
7 => NatType.SymmetricFirewall,
8 => NatType.UdpBlocked,
_ => NatType.Unknown
};
public static string GetNatTypeString(NatType type)
{
return Lang.Text(type switch
{
NatType.OpenInternet or NatType.NoPat => "Link.Nat.Type.Open",
NatType.FullCone => "Link.Nat.Type.FullCone",
NatType.PortRestricted => "Link.Nat.Type.PortRestricted",
NatType.Restricted => "Link.Nat.Type.Restricted",
NatType.SymmetricEasy => "Link.Nat.Type.SymmetricEasy",
NatType.Symmetric => "Link.Nat.Type.Symmetric",
NatType.SymmetricFirewall => "Link.Nat.Type.SymmetricFirewall",
NatType.UdpBlocked => "Link.Nat.Type.UdpBlocked",
_ => "Link.Nat.Type.Unknown"
});
}
}
@@ -0,0 +1,23 @@
namespace PCL.Core.Link.Scaffolding.EasyTier;
public enum ConnectionWay
{
Local,
P2P,
Relay,
Unknown
}
public record EasyPlayerInfo
{
public required bool IsHost { get; init; }
public required string HostName { get; init; }
public required string Ip { get; init; }
public string UserName { get; init; } = string.Empty;
public string MinecraftName { get; init; } = string.Empty;
public ConnectionWay Way { get; init; } = ConnectionWay.Unknown;
public double Ping { get; init; }
public double Loss { get; init; }
public string NatType { get; init; } = string.Empty;
public string EasyTierVer { get; init; } = string.Empty;
}
@@ -0,0 +1,583 @@
using PCL.Core.App;
using PCL.Core.Link.EasyTier;
using PCL.Core.Link.Scaffolding.Client.Models;
using PCL.Core.Logging;
using PCL.Core.Utils;
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Threading;
using System.Threading.Tasks;
using PCL.Core.IO.Net;
using PCL.Core.IO.Net.Http;
namespace PCL.Core.Link.Scaffolding.EasyTier;
/// <summary>
/// Demonstrates the state of EasyTier entity.
/// </summary>
public enum EtState
{
Stopped,
Active,
Ready
}
/// <summary>
/// An EasyTier entity that manages the EasyTier process and its interactions.
/// </summary>
public class EasyTierEntity
{
private readonly Process _etProcess;
private readonly int _rpcPort;
private readonly LobbyInfo _lobby;
private readonly int _scfPort;
public int ForwardPort { get; private set; }
public int MinecraftPort { get; init; }
public EtState State { get; private set; }
public LobbyInfo Lobby => _lobby;
public event Action? EasyTierProcessExisted;
/// <summary>
/// Constructor of EasyTierEntity
/// </summary>
/// <param name="lobby">The room information.</param>
/// <param name="minecraftPort">Minecraft port.</param>
/// <param name="scfPort">The server port.</param>
/// <param name="asHost">Indicates whether the entity acts as a host.</param>
/// <exception cref="FileNotFoundException">Thrown if EasyTier was broken.</exception>
public EasyTierEntity(LobbyInfo lobby, int minecraftPort, int scfPort, bool asHost)
{
_lobby = lobby;
MinecraftPort = minecraftPort;
_scfPort = scfPort;
State = EtState.Stopped;
var existEntities = Process.GetProcessesByName("easytier-core");
foreach (var entity in existEntities)
{
LogWrapper.Warn("EasyTier", $"Find exist EasyTier Entity, may affect something: {entity.Id}");
}
LogWrapper.Info("EasyTier", $"EasyTier folder path: {EasyTierMetadata.EasyTierFilePath}");
if (!(File.Exists($"{EasyTierMetadata.EasyTierFilePath}\\easytier-core.exe") &&
File.Exists($"{EasyTierMetadata.EasyTierFilePath}\\easytier-cli.exe") &&
File.Exists($"{EasyTierMetadata.EasyTierFilePath}\\Packet.dll")))
{
LogWrapper.Error("EasyTier", "EasyTier was broken.");
throw new FileNotFoundException("EasyTier was broken.");
}
State = EtState.Ready;
_rpcPort = NetworkHelper.NewTcpPort();
ForwardPort = NetworkHelper.NewTcpPort();
_etProcess = _BuildProcessAsync(asHost).GetAwaiter().GetResult();
}
/// <summary>
/// Launches EasyTier process.
/// </summary>
/// <returns>
/// - 1 means failed to launch EasyTier and can never launch again.<br/>
/// - 0 means successful launch.
/// </returns>
public int Launch()
{
LogWrapper.Info("EasyTier", "Launch EasyTier Core.");
try
{
// LogWrapper.Info("Test", _etProcess.StartInfo.Arguments);
_etProcess.Start();
State = EtState.Active;
var cli = _GetCliOutputDebugAsync();
_etProcess.Exited += (_, _) => EasyTierProcessExisted?.Invoke();
}
catch (Exception ex)
{
LogWrapper.Error(ex, "EasyTier", "Failed to launch EasyTier.");
State = EtState.Stopped;
return 1;
}
return 0;
}
/// <summary>
/// Stops EasyTier process.
/// </summary>
/// <returns>
/// - 1 means failed to stop EasyTier.<br/>
/// - 0 means successful stop.
/// </returns>
public Task<int> StopAsync()
{
return Task.Run(() =>
{
try
{
if (!_etProcess.HasExited)
{
_etProcess.Kill(true);
_etProcess.WaitForExit(5000);
}
State = EtState.Stopped;
return 0;
}
catch (Exception ex)
{
LogWrapper.Error(ex, "EasyTier", "Failed to stop EasyTier.");
State = EtState.Stopped;
return 1;
}
});
}
private async Task<Process> _BuildProcessAsync(bool asHost)
{
var process = new Process
{
EnableRaisingEvents = true,
StartInfo = new ProcessStartInfo
{
FileName = Path.Combine(EasyTierMetadata.EasyTierFilePath, "easytier-core.exe"),
WorkingDirectory = EasyTierMetadata.EasyTierFilePath,
WindowStyle = ProcessWindowStyle.Hidden
}
};
var args = new ArgumentsBuilder();
args.AddFlag("no-tun")
.AddFlag("multi-thread")
.AddFlag("enable-kcp-proxy")
.AddFlag("enable-quic-proxy")
.AddFlagIf(!Config.Link.TryPunchSym, "disable-sys-hole-punching")
.AddFlagIf(!Config.Link.EnableIPv6, "disable-ipv6")
.AddFlagIf(Config.Link.UseLatencyFirstMode, "latency-first")
.Add("encryption-algorithm", "aes-gcm")
.Add("compression", "zstd")
.Add("default-protocol", Config.Link.ProtocolPreference.ToString().ToLowerInvariant())
.Add("network-name", _lobby.NetworkName)
.Add("network-secret", _lobby.NetworkSecret)
//.Add("relay-network-whitelist", _lobby.NetworkName)
.Add("machine-id", Utils.Secret.Identify.LauncherId)
.Add("rpc-portal", _rpcPort.ToString())
.Add("private-mode", "true")
.AddFlag("p2p-only");
if (asHost)
{
args.AddWithSpace("i", "10.114.51.41")
.Add("hostname", $"scaffolding-mc-server-{_scfPort}")
.Add("tcp-whitelist", _scfPort.ToString())
.Add("udp-whitelist", _scfPort.ToString())
.Add("tcp-whitelist", MinecraftPort.ToString())
.Add("udp-whitelist", MinecraftPort.ToString())
.Add("l", "tcp://0.0.0.0:0")
.Add("l", "udp://0.0.0.0:0");
}
else
{
args.AddFlag("d")
.Add("hostname", Guid.NewGuid().ToString())
.Add("tcp-whitelist", "0")
.Add("udp-whitelist", "0")
.Add("l", "tcp://0.0.0.0:0")
.Add("l", "udp://0.0.0.0:0");
}
foreach (var address in ETRelay.RelayList
.Select(static x => x.Url)
.Concat(_fallbackNodeLinks))
{
args.Add("p", address);
}
// foreach (var address in await _GetEtRelayListAsync().ConfigureAwait(false))
// {
// args.Add("p", address);
// }
// if (Config.Link.RelayType == 1)
// {
// args.AddFlag("disable-p2p");
// }
process.StartInfo.Arguments = args.GetResult();
LogWrapper.Debug("EasyTier", process.StartInfo.Arguments);
return process;
}
private async Task<IReadOnlyList<string>> _GetEtRelayListAsync()
{
var relays = ETRelay.RelayList;
var customedNodes = Config.Link.CustomRelayServer.Split(';', StringSplitOptions.RemoveEmptyEntries);
foreach (var node in customedNodes)
{
if (node.Contains("tcp://", StringComparison.OrdinalIgnoreCase) ||
node.Contains("udp://", StringComparison.OrdinalIgnoreCase))
{
relays.Add(new ETRelay
{
Url = node,
Name = "Custom",
Type = ETRelayType.Custom
});
}
else
{
LogWrapper.Warn("EasyTier", $"Invalid custom node URL: {node}.");
}
}
var setupRelayList = relays.Select(relay => new { relay, serverType = Config.Link.ServerType })
.Where(rl =>
(rl.relay.Type == ETRelayType.Selfhosted && rl.serverType != 2) ||
(rl.relay.Type == ETRelayType.Community && rl.serverType == 1) ||
rl.relay.Type == ETRelayType.Custom)
.Select(rl => rl.relay.Url).ToImmutableList();
var pubNode = await _GetPublicNodeAsync().ConfigureAwait(false);
var result = setupRelayList
.Concat(pubNode)
.Take(6)
.ToImmutableList();
LogWrapper.Debug($"Get public node:\n{string.Join("\n\t", result)}");
return result;
}
private readonly string[] _fallbackNodeLinks =
[
"tcp://public.easytier.top:11010",
"tcp://public2.easytier.cn:54321",
"https://etnode.zkitefly.eu.org/node1",
"https://etnode.zkitefly.eu.org/node2",
"https://etnode.zkitefly.eu.org/-node1",
"https://etnode.zkitefly.eu.org/-node2"
];
private async Task<IReadOnlyList<string>> _GetPublicNodeAsync()
{
using var rep = await HttpRequest
.Create("https://uptime.easytier.cn/api/nodes?page=1&per_page=50&is_active=true")
.SendAsync()
.ConfigureAwait(false);
rep.EnsureSuccessStatusCode();
var dto = await rep
.AsJsonAsync<PublicNodeDto>()
.ConfigureAwait(false);
ArgumentNullException.ThrowIfNull(dto);
var result = dto.Data.Items
.Where(it => it is { IsActive: true, IsAllowRelay: true })
.Select(it => it.Host)
.Union(_fallbackNodeLinks)
.ToImmutableList();
return result;
}
#region Information
/// <summary>
/// Checks the status of EasyTier network until it is ready or time-out.
/// </summary>
/// <returns>Returns 0 when the network is ready, otherwise returns 1 for timeout.</returns>
public async Task<(bool, EtPlayerList?)> CheckEasyTierStatusAsync(CancellationToken ct = default)
{
var retryCount = 0;
while (_etProcess is null && retryCount < 10)
{
ct.ThrowIfCancellationRequested();
await Task.Delay(1000, ct).ConfigureAwait(false);
retryCount++;
}
if (_etProcess is null)
{
return (false, null);
}
retryCount = 0;
while (State is EtState.Active && retryCount < 300)
{
ct.ThrowIfCancellationRequested();
var info = await _GetPlayersAsync().ConfigureAwait(false);
if (info.Host is null)
{
LogWrapper.Debug("EasyTierEntity", "Retry to get EasyTier Info.");
await Task.Delay(1000, ct).ConfigureAwait(false);
retryCount++;
continue;
}
LogWrapper.Debug("EtEntity", "Successfully to get player info from EasyTier CLI.");
if (info.Host.Ping < 1000)
{
State = EtState.Ready;
return (true, info);
}
await Task.Delay(1000, ct).ConfigureAwait(false);
retryCount++;
}
LogWrapper.Debug("EtEntity", "Failed to get player info from EasyTier CLI.");
return (false, null);
}
/// <summary>
/// Add a port forward to the EasyTier instance.
/// </summary>
/// <param name="targetIp">Remote IP</param>
/// <param name="targetPort">Remote Port</param>
/// <returns>Forwarded local port</returns>
public async Task<int> AddPortForwardAsync(string targetIp, int targetPort)
{
var localPort = NetworkHelper.NewTcpPort();
using var cliProcess = new Process();
cliProcess.StartInfo = new ProcessStartInfo
{
FileName = $"{EasyTierMetadata.EasyTierFilePath}\\easytier-cli.exe",
WorkingDirectory = EasyTierMetadata.EasyTierFilePath,
ErrorDialog = false,
CreateNoWindow = true,
WindowStyle = ProcessWindowStyle.Hidden,
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
RedirectStandardInput = true,
StandardOutputEncoding = Encoding.UTF8,
StandardErrorEncoding = Encoding.UTF8,
StandardInputEncoding = Encoding.UTF8
};
cliProcess.EnableRaisingEvents = true;
try
{
cliProcess.StartInfo.Arguments =
$"--rpc-portal 127.0.0.1:{_rpcPort} port-forward add tcp 127.0.0.1:{localPort} {targetIp}:{targetPort}";
cliProcess.Start();
await cliProcess.WaitForExitAsync().ConfigureAwait(false);
cliProcess.StartInfo.Arguments =
$"--rpc-portal 127.0.0.1:{_rpcPort} port-forward add udp 127.0.0.1:{localPort} {targetIp}:{targetPort}";
cliProcess.Start();
await cliProcess.WaitForExitAsync().ConfigureAwait(false);
cliProcess.StartInfo.Arguments =
$"--rpc-portal 127.0.0.1:{_rpcPort} port-forward add tcp [::]:{localPort} {targetIp}:{targetPort}";
cliProcess.Start();
await cliProcess.WaitForExitAsync().ConfigureAwait(false);
cliProcess.StartInfo.Arguments =
$"--rpc-portal 127.0.0.1:{_rpcPort} port-forward add udp [::]:{localPort} {targetIp}:{targetPort}";
cliProcess.Start();
await cliProcess.WaitForExitAsync().ConfigureAwait(false);
LogWrapper.Debug("ET Cli", await cliProcess.StandardOutput.ReadToEndAsync().ConfigureAwait(false) +
await cliProcess.StandardError.ReadToEndAsync().ConfigureAwait(false));
}
catch (Exception e)
{
LogWrapper.Error(e, "ET Cli", "Failed to add port forward.");
}
return localPort;
}
private async Task _GetCliOutputDebugAsync()
{
while (State != EtState.Stopped && Config.Link.EnableCliOutput)
{
using var cliProcess = new Process();
cliProcess.StartInfo = new ProcessStartInfo
{
FileName = $"{EasyTierMetadata.EasyTierFilePath}\\easytier-cli.exe",
WorkingDirectory = EasyTierMetadata.EasyTierFilePath,
Arguments = $"--rpc-portal 127.0.0.1:{_rpcPort} peer",
ErrorDialog = false,
CreateNoWindow = true,
WindowStyle = ProcessWindowStyle.Hidden,
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
RedirectStandardInput = true,
StandardOutputEncoding = Encoding.UTF8,
StandardErrorEncoding = Encoding.UTF8,
StandardInputEncoding = Encoding.UTF8
};
using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(5000));
try
{
cliProcess.Start();
cliProcess.StandardInput.Close();
var stdOut = await cliProcess.StandardOutput.ReadToEndAsync(cts.Token).ConfigureAwait(false);
var stdErr = await cliProcess.StandardError.ReadToEndAsync(cts.Token).ConfigureAwait(false);
await cliProcess.WaitForExitAsync(cts.Token).ConfigureAwait(false);
var output = stdOut + stdErr;
LogWrapper.Info("EasyTier Cli Debug", "EasyTier Cli 抽样输出: \n" + output);
}
catch (Exception e)
{
LogWrapper.Error(e, "EasyTier Cli", "Failed to get EasyTier Cli info");
}
await Task.Delay(30000);
}
}
/// <exception cref="ArgumentException">Thrown if host is duplicated.</exception>
private async Task<EtPlayerList> _GetPlayersAsync()
{
using var cliProcess = new Process();
cliProcess.StartInfo = new ProcessStartInfo
{
FileName = $"{EasyTierMetadata.EasyTierFilePath}\\easytier-cli.exe",
WorkingDirectory = EasyTierMetadata.EasyTierFilePath,
Arguments = $"--rpc-portal 127.0.0.1:{_rpcPort} -o json peer",
ErrorDialog = false,
CreateNoWindow = true,
WindowStyle = ProcessWindowStyle.Hidden,
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
RedirectStandardInput = true,
StandardOutputEncoding = Encoding.UTF8,
StandardErrorEncoding = Encoding.UTF8,
StandardInputEncoding = Encoding.UTF8
};
cliProcess.EnableRaisingEvents = true;
using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(5000));
try
{
LogWrapper.Debug("Et Cli", "Trying to get player info.");
cliProcess.Start();
cliProcess.StandardInput.Close();
var stdOut = await cliProcess.StandardOutput.ReadToEndAsync(cts.Token).ConfigureAwait(false);
var stdErr = await cliProcess.StandardError.ReadToEndAsync(cts.Token).ConfigureAwait(false);
await cliProcess.WaitForExitAsync(cts.Token).ConfigureAwait(false);
var output = stdOut + stdErr;
//LogWrapper.Debug("ET Cli", output);
if (JsonCompat.ParseNode(output) is not JsonArray jArray)
{
return new EtPlayerList(null, null);
}
List<EasyPlayerInfo> players = [];
EasyPlayerInfo? host = null;
foreach (var arr in jArray)
{
LogWrapper.Debug("Et Cli", "Getting player info.");
var info = arr.Deserialize<ETPeerInfo>(JsonCompat.SerializerOptions);
if (info is null)
{
LogWrapper.Debug("Et Cli", "Player info is null.");
continue;
}
if (info.Hostname.StartsWith("scaffolding-mc-server-", StringComparison.Ordinal))
{
LogWrapper.Debug("Et Cli", $"Find host player: {info.Hostname}");
if (host is not null)
{
LogWrapper.Debug("Et Cli", "Duplicated host player.");
throw new ArgumentException("Duplicated host.", nameof(host));
}
host = _ConvertPeerToPlayer(info);
continue;
}
LogWrapper.Debug("Et Cli", $"Find player: {info.Hostname}");
players.Add(_ConvertPeerToPlayer(info));
}
LogWrapper.Debug("Et Cli", "Return from GetPlayersAsync().");
var result = host is null ? players : [host, .. players];
return new EtPlayerList(result, host);
}
catch (TaskCanceledException tce)
{
LogWrapper.Error(tce, "EasyTier", "Failed to read CLI output.");
return new EtPlayerList(null, null);
}
catch (ArgumentException)
{
throw;
}
catch (Exception ex)
{
LogWrapper.Error(ex, "EasyTier", "Failed to get EasyTier player list info.");
return new EtPlayerList(null, null);
}
}
private static EasyPlayerInfo _ConvertPeerToPlayer(ETPeerInfo info)
{
var playerInfo = new EasyPlayerInfo
{
IsHost = info.Hostname.StartsWith("scaffolding-mc-server", StringComparison.Ordinal),
HostName = info.Hostname,
Ip = info.Ipv4,
Ping = Math.Round(Convert.ToDouble(info.Ping != "-" ? info.Ping : "0")),
Loss = Math.Round(Convert.ToDouble(info.Loss != "-" ? info.Loss.Replace("%", "") : "0")),
NatType = info.NatType,
EasyTierVer = info.ETVersion
};
return playerInfo;
}
#endregion
}
public record EtPlayerList(IReadOnlyList<EasyPlayerInfo>? Players, EasyPlayerInfo? Host);
@@ -0,0 +1,14 @@
using System.IO;
using System.Runtime.InteropServices;
using PCL.Core.App;
namespace PCL.Core.Link.Scaffolding.EasyTier;
public static class EasyTierMetadata
{
public const string CurrentEasyTierVer = "2.6.4";
public static string EasyTierFilePath => Path.Combine(Paths.SharedLocalData, "EasyTier",
CurrentEasyTierVer,
$"easytier-windows-{(RuntimeInformation.OSArchitecture == Architecture.Arm64 ? "arm64" : "x86_64")}");
}
@@ -0,0 +1,22 @@
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace PCL.Core.Link.Scaffolding.EasyTier;
internal record PublicNodeDto
{
[JsonPropertyName("success")] public bool IsSuccess { get; init; }
[JsonPropertyName("data")] public required NodeDataDto Data { get; init; }
}
internal record NodeDataDto
{
[JsonPropertyName("items")] public required IReadOnlyList<NodeItemDto> Items { get; init; }
}
internal record NodeItemDto
{
[JsonPropertyName("address")] public required string Host { get; init; }
[JsonPropertyName("allow_relay")] public bool IsAllowRelay { get; init; }
[JsonPropertyName("is_active")] public bool IsActive { get; init; }
}
@@ -0,0 +1,10 @@
using System.Text.Json.Serialization;
namespace PCL.Core.Link.Scaffolding.EasyTier;
public record StunInfo
{
[JsonPropertyName("udp_nat_type")] public required int UdpNatType { get; init; }
[JsonPropertyName("tcp_nat_type")] public required int TcpNatType { get; init; }
[JsonPropertyName("public_ip")] public required string[] Ips { get; init; }
}
@@ -0,0 +1,14 @@
using System;
namespace PCL.Core.Link.Scaffolding.Exceptions;
public class FailedToGetPlayerException : Exception
{
public FailedToGetPlayerException() : base()
{
}
public FailedToGetPlayerException(string msg) : base(msg)
{
}
}
@@ -0,0 +1,35 @@
using System;
namespace PCL.Core.Link.Scaffolding.Exceptions;
/// <summary>
/// Exception thrown when a scaffolding request fails with a non-success status code.
/// </summary>
public class ScaffoldingRequestException(byte statusCode, string? serverMessage = null) : Exception(_BuildMessage(
statusCode,
serverMessage))
{
/// <summary>
/// The status code returned by the scaffolding server.
/// </summary>
public byte StatusCode { get; } = statusCode;
/// <summary>
/// The error message or details returned by the server, if any.
/// </summary>
public string? ServerMessage { get; } = serverMessage;
private static string _BuildMessage(byte statusCode, string? serverMessage)
{
var errorType = statusCode switch
{
>= 32 and < 64 => "Protocol-defined error",
255 => "Unknow error",
_ => "Generic error"
};
return string.IsNullOrEmpty(serverMessage)
? $"Scaffolding request failed with status code {statusCode} ({errorType})."
: $"Scaffolding request failed with status code {statusCode} ({errorType}): {serverMessage}";
}
}
@@ -0,0 +1,168 @@
// This code is from terracota project.
// Thanks for Burning_TNT's contribution!
using PCL.Core.Link.Scaffolding.Client.Models;
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Runtime.InteropServices;
using System.Security.Cryptography;
namespace PCL.Core.Link.Scaffolding;
public static class LobbyCodeGenerator
{
private const string Chars = "0123456789ABCDEFGHJKLMNPQRSTUVWXYZ";
private const string FullCodePrefix = "U/";
private const string NetworkNamePrefix = "scaffolding-mc-";
private const int BaseVal = 34;
private const int DataLength = 16; // NNNN NNNN SSSS SSSS (16 chars)
private const int HyphenCount = 3;
private const int PayloadLength = DataLength + HyphenCount; // 19
private const int CodeLength = PayloadLength + 2; // 21 ("U/")
private static readonly UInt128 _EncodingMaxValue;
private static readonly Dictionary<char, byte> _CharToValueMap;
static LobbyCodeGenerator()
{
_EncodingMaxValue = _CalculatePower(BaseVal, DataLength);
_CharToValueMap = new Dictionary<char, byte>(36);
for (byte i = 0; i < Chars.Length; i++)
{
_CharToValueMap[Chars[i]] = i;
}
_CharToValueMap['I'] = 1;
_CharToValueMap['O'] = 0;
}
public static LobbyInfo Generate()
{
var randomValue = _GetSecureRandomUInt128();
var valueInRange = randomValue % _EncodingMaxValue;
var remainder = valueInRange % 7;
var validValue = randomValue - remainder;
return _Encode(validValue);
}
public static bool TryParse(string input, [NotNullWhen(true)] out LobbyInfo? roomInfo)
{
roomInfo = null;
if (string.IsNullOrWhiteSpace(input) ||
!input.StartsWith(FullCodePrefix, StringComparison.Ordinal) ||
input.Length != 21)
{
return false;
}
Span<byte> values = stackalloc byte[DataLength];
var valueIndex = 0;
var payloadSpan = input.AsSpan(FullCodePrefix.Length);
for (var i = 0; i < payloadSpan.Length; i++)
{
var ch = payloadSpan[i];
if (ch == '-')
{
if (i != 4 && i != 9 && i != 14)
{
return false;
}
continue;
}
if (valueIndex >= DataLength ||
!_CharToValueMap.TryGetValue(char.ToUpperInvariant(ch), out var charValue))
{
return false;
}
values[valueIndex++] = charValue;
}
if (valueIndex != DataLength)
{
return false;
}
UInt128 value = 0;
for (var i = DataLength - 1; i >= 0; i--)
{
value = value * BaseVal + values[i];
}
if (value % 7 != 0)
{
return false;
}
var networkNamePayload = payloadSpan[..9];
var networkSecretPayload = payloadSpan[10..];
roomInfo = new LobbyInfo(
string.Concat(FullCodePrefix, payloadSpan).ToUpperInvariant(),
string.Concat(NetworkNamePrefix, networkNamePayload),
networkSecretPayload.ToString());
return true;
}
private static LobbyInfo _Encode(UInt128 value)
{
var codePayload = string.Create(PayloadLength, value, (span, val) =>
{
Span<char> tempChars = stackalloc char[DataLength];
for (var i = 0; i < DataLength; i++)
{
tempChars[i] = Chars[(int)(val % BaseVal)];
val /= BaseVal;
}
tempChars[..4].CopyTo(span[..4]);
span[4] = '-';
tempChars[4..8].CopyTo(span[5..9]);
span[9] = '-';
tempChars[8..12].CopyTo(span[10..14]);
span[14] = '-';
tempChars[12..16].CopyTo(span[15..]);
});
var networkNamePayload = codePayload.AsSpan(0, 9);
var networkSecretPayload = codePayload.AsSpan(10);
return new LobbyInfo(
string.Concat(FullCodePrefix, codePayload),
string.Concat(NetworkNamePrefix, networkNamePayload),
networkSecretPayload.ToString());
}
private static UInt128 _GetSecureRandomUInt128()
{
Span<byte> bytes = stackalloc byte[16];
RandomNumberGenerator.Fill(bytes);
var lower = MemoryMarshal.Read<ulong>(bytes);
var upper = MemoryMarshal.Read<ulong>(bytes[8..]);
return new UInt128(lower, upper);
}
private static UInt128 _CalculatePower(uint baseVal, int exp)
{
UInt128 result = 1;
for (var i = 0; i < exp; i++)
{
result *= baseVal;
}
return result;
}
}
@@ -0,0 +1,24 @@
using PCL.Core.Link.Scaffolding.Client.Models;
using System.Collections.Generic;
namespace PCL.Core.Link.Scaffolding;
public class PlayerListHandler
{
public static List<PlayerProfile> Sort(IReadOnlyList<PlayerProfile> list)
{
var sorted = new List<PlayerProfile>();
foreach (var profile in list)
{
if (profile.Kind == PlayerKind.HOST)
{
sorted.Insert(0, profile);
}
else
{
sorted.Add(profile);
}
}
return sorted;
}
}
@@ -0,0 +1,105 @@
using PCL.Core.Link.Scaffolding.Client;
using PCL.Core.Link.Scaffolding.Client.Models;
using PCL.Core.Link.Scaffolding.EasyTier;
using PCL.Core.Link.Scaffolding.Exceptions;
using PCL.Core.Link.Scaffolding.Server;
using PCL.Core.App;
using System;
using System.Threading;
using System.Threading.Tasks;
using PCL.Core.IO.Net;
namespace PCL.Core.Link.Scaffolding;
public static class ScaffoldingFactory
{
// Please update ScaffoldingServerContext.cs at the same time.
private static readonly string _LobbyVendor = $"PCL CE {Basics.VersionName}, EasyTier {EasyTierMetadata.CurrentEasyTierVer}";
private const string HostIp = "10.114.51.41";
/// <exception cref="ArgumentException">Invalid lobby code.</exception>
/// <exception cref="FailedToGetPlayerException">Thrown if failed to get host player info.</exception>
/// <exception cref="InvalidOperationException">Failed to get EasyTier Info.</exception>
public static async Task<ScaffoldingClientEntity> CreateClientAsync
(string playerName, string lobbyCode, LobbyType from, CancellationToken ct = default)
{
var machineId = Utils.Secret.Identify.LauncherId;
if (!LobbyCodeGenerator.TryParse(lobbyCode, out var info))
{
throw new ArgumentException("Invalid lobby code.", nameof(lobbyCode));
}
var etEntity = _CreateEasyTierEntity(info, 0, 0, false);
etEntity.Launch();
try
{
var (etStatus, players) = await etEntity.CheckEasyTierStatusAsync(ct).ConfigureAwait(false);
if (!etStatus || players is null)
{
throw new InvalidOperationException("Failed to get EasyTier Info.");
}
if (players.Players is null)
{
throw new FailedToGetPlayerException();
}
var hostInfo = players.Host;
if (hostInfo is null)
{
throw new FailedToGetPlayerException("Can not get the host information.");
}
if (!int.TryParse(hostInfo.HostName[22..], out var scfPort))
{
throw new ArgumentException("Invalid hostname.", nameof(hostInfo));
}
var localPort = await etEntity.AddPortForwardAsync(hostInfo.Ip, scfPort).ConfigureAwait(false);
var client = new ScaffoldingClient("127.0.0.1", localPort, playerName, machineId, _LobbyVendor);
return new ScaffoldingClientEntity(client, etEntity, hostInfo);
}
catch
{
await etEntity.StopAsync().ConfigureAwait(false);
throw;
}
}
/// <summary>
/// Create Scaffolding Server.
/// </summary>
/// <param name="mcPort">Target forward Miencraft shared port.</param>
/// <param name="playerName">Game player name.</param>
/// <returns></returns>
/// <exception cref="InvalidOperationException">Fialed to launch EasyTier Core.</exception>
public static ScaffoldingServerEntity CreateServer(int mcPort, string playerName)
{
var context = ScaffoldingServerContext.Create(playerName, mcPort);
var scfPort = NetworkHelper.NewTcpPort();
var etEntity = _CreateEasyTierEntity(context.UserLobbyInfo, mcPort, scfPort, true);
var res = etEntity.Launch();
if (res != 0)
{
throw new InvalidOperationException("Failed to launch EasyTier Core.");
}
var server = new ScaffoldingServer(scfPort, context);
return new ScaffoldingServerEntity(server, etEntity);
}
private static EasyTierEntity _CreateEasyTierEntity(LobbyInfo lobby, int mcPort, int port, bool asHost) =>
new(lobby, mcPort, port, asHost);
}
public record ScaffoldingClientEntity(ScaffoldingClient Client, EasyTierEntity EasyTier, EasyPlayerInfo HostInfo);
public record ScaffoldingServerEntity(ScaffoldingServer Server, EasyTierEntity EasyTier);
@@ -0,0 +1,24 @@
using System;
using System.Threading;
using System.Threading.Tasks;
namespace PCL.Core.Link.Scaffolding.Server.Abstractions;
public interface IRequestHandler
{
/// <summary>
/// Gets the request type this handler is responsible for, e.g., "c:ping".
/// </summary>
string RequestType { get; }
/// <summary>
/// Handle an incoming request and returns a response.
/// </summary>
/// <param name="requestBody">The raw body of the request.</param>
/// <param name="context">The shared server context.</param>
/// <param name="sessionId">A unique identifier for the client session.</param>
/// <param name="ct">A token to cancel the operation.</param>
/// <returns>A tuple containing the status code and the response body.</returns>
Task<(byte Status, ReadOnlyMemory<byte> Body)> HandleAsync
(ReadOnlyMemory<byte> requestBody, IServerContext context, string sessionId, CancellationToken ct);
}
@@ -0,0 +1,45 @@
using PCL.Core.Link.Scaffolding.Client.Models;
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
namespace PCL.Core.Link.Scaffolding.Server.Abstractions;
public interface IServerContext
{
/// <summary>
/// Get the list of currently connected player profiles.
/// </summary>
IReadOnlyList<PlayerProfile> PlayerProfiles { get; }
/// <summary>
/// Gets the list of currently connected player profiles, keyed by a unique session identifier.
/// </summary>
ConcurrentDictionary<string, TrackedPlayerProfile> TrackedPlayers { get; }
/// <summary>
/// Occurs on player profile changed.
/// </summary>
void OnPlayerProfilesChanged();
/// <summary>
/// Occurs when the list of player profiles changes.
/// </summary>
event Action<IReadOnlyList<PlayerProfile>> PlayerProfilesPing;
/// <summary>
/// Gets the prot of the running Minecraft server.
/// </summary>
int MinecraftServerProt { get; }
/// <summary>
/// Gets the room information.
/// </summary>
LobbyInfo UserLobbyInfo { get; }
/// <summary>
/// Player name(Host).
/// </summary>
string PlayerName { get; }
}
@@ -0,0 +1,43 @@
using PCL.Core.Link.Scaffolding.Client.Models;
using PCL.Core.Link.Scaffolding.Server.Abstractions;
using PCL.Core.Link.Scaffolding.EasyTier;
using PCL.Core.App;
using System;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
using PCL.Core.Utils;
namespace PCL.Core.Link.Scaffolding.Server.Handlers;
public class GetPlayerProfileListHandler : IRequestHandler
{
/// <inheritdoc />
public string RequestType { get; } = "c:player_profiles_list";
private static readonly JsonSerializerOptions _JsonOptions = new(JsonCompat.SerializerOptions)
{
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
/// <inheritdoc />
public Task<(byte Status, ReadOnlyMemory<byte> Body)> HandleAsync(ReadOnlyMemory<byte> requestBody,
IServerContext context, string sessionId, CancellationToken ct)
{
var hostProfile = new PlayerProfile
{
Name = context.PlayerName,
MachineId = Utils.Secret.Identify.LauncherId,
Vendor = $"PCL CE {Basics.VersionName}, EasyTier {EasyTierMetadata.CurrentEasyTierVer}",
Kind = PlayerKind.HOST
};
var allProfiles = context.PlayerProfiles;
var responseBody = JsonSerializer.SerializeToUtf8Bytes(allProfiles, _JsonOptions);
return Task.FromResult(((byte)0, new ReadOnlyMemory<byte>(responseBody)));
}
}
@@ -0,0 +1,32 @@
using System;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using PCL.Core.Link.Scaffolding.Server.Abstractions;
namespace PCL.Core.Link.Scaffolding.Server.Handlers;
public class GetProtocolsHandler : IRequestHandler
{
/// <inheritdoc />
public string RequestType { get; } = "c:protocols";
/// <inheritdoc />
public Task<(byte Status, ReadOnlyMemory<byte> Body)> HandleAsync(ReadOnlyMemory<byte> requestBody,
IServerContext context, string sessionId, CancellationToken ct)
{
string[] protocols =
[
"c:ping",
"c:protocols",
"c:server_port",
"c:player_ping",
"c:player_profiles_list"
];
var rseponseContent = string.Join('\0', protocols);
var responseBody = Encoding.ASCII.GetBytes(rseponseContent);
return Task.FromResult(((byte)0, new ReadOnlyMemory<byte>(responseBody)));
}
}
@@ -0,0 +1,33 @@
using PCL.Core.Link.Scaffolding.Server.Abstractions;
using System;
using System.Buffers.Binary;
using System.Threading;
using System.Threading.Tasks;
namespace PCL.Core.Link.Scaffolding.Server.Handlers;
public class GetServerPortHandler : IRequestHandler
{
/// <inheritdoc />
public string RequestType { get; } = "c:server_port";
/// <inheritdoc />
public Task<(byte Status, ReadOnlyMemory<byte> Body)> HandleAsync(ReadOnlyMemory<byte> requestBody,
IServerContext context, string sessionId, CancellationToken ct)
{
var port = context.MinecraftServerProt;
if (port == 0)
{
return Task.FromResult<(byte, ReadOnlyMemory<byte>)>((32, ReadOnlyMemory<byte>.Empty));
}
var portBytes = new byte[2];
var portAsUshort = (ushort)port;
BinaryPrimitives.WriteUInt16BigEndian(portBytes.AsSpan(), portAsUshort);
return Task.FromResult<(byte, ReadOnlyMemory<byte>)>((0, portBytes));
}
}
@@ -0,0 +1,19 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using PCL.Core.Link.Scaffolding.Server.Abstractions;
namespace PCL.Core.Link.Scaffolding.Server.Handlers;
public class PingHandler : IRequestHandler
{
/// <inheritdoc />
public string RequestType { get; } = "c:ping";
/// <inheritdoc />
public Task<(byte Status, ReadOnlyMemory<byte> Body)> HandleAsync(ReadOnlyMemory<byte> requestBody,
IServerContext context, string sessionId, CancellationToken ct)
{
return Task.FromResult((Status: (byte)0, Body: requestBody));
}
}
@@ -0,0 +1,72 @@
using PCL.Core.Link.Scaffolding.Client.Models;
using PCL.Core.Link.Scaffolding.Server.Abstractions;
using PCL.Core.Logging;
using System;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
using PCL.Core.Utils;
namespace PCL.Core.Link.Scaffolding.Server.Handlers;
public class PlayerPingHandler : IRequestHandler
{
/// <inheritdoc />
public string RequestType { get; } = "c:player_ping";
private static readonly JsonSerializerOptions _JsonOptions = new(JsonCompat.SerializerOptions)
{
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
/// <inheritdoc />
public Task<(byte Status, ReadOnlyMemory<byte> Body)> HandleAsync(ReadOnlyMemory<byte> requestBody,
IServerContext context, string sessionId, CancellationToken ct)
{
try
{
var profile = JsonSerializer.Deserialize<PlayerProfile>(requestBody.Span, _JsonOptions);
if (profile is null || string.IsNullOrEmpty(profile.MachineId))
{
LogWrapper.Warn("ScaffoldingServer",
$"Received a player_ping from session {sessionId} with a missing or empty machine_id. Ignoring.");
return Task.FromResult(((byte)32, ReadOnlyMemory<byte>.Empty));
}
var guestProfile = profile with { Kind = PlayerKind.GUEST };
var listChanged = false;
context.TrackedPlayers.AddOrUpdate(guestProfile.MachineId,
_ =>
{
LogWrapper.Info("ScaffoldingServer",
$"New player '{guestProfile.Name}' with machine_id '{guestProfile.MachineId}' connected.");
var newPlayer = new TrackedPlayerProfile { Profile = profile, LastSeenUtc = DateTime.UtcNow };
listChanged = true;
return newPlayer;
},
(_, existingPlayer) =>
{
existingPlayer.Profile = guestProfile;
existingPlayer.LastSeenUtc = DateTime.UtcNow;
return existingPlayer;
});
if (listChanged)
{
context.OnPlayerProfilesChanged();
}
return Task.FromResult(((byte)0, ReadOnlyMemory<byte>.Empty));
}
catch (JsonException ex)
{
LogWrapper.Warn("ScaffoldingServer",
$"Failed to deserialize player_ping JSON from session {sessionId}. Error: {ex.Message}");
return Task.FromResult(((byte)32, ReadOnlyMemory<byte>.Empty));
}
}
}
@@ -0,0 +1,383 @@
using PCL.Core.Link.Scaffolding.Client.Models;
using PCL.Core.Link.Scaffolding.Server.Abstractions;
using PCL.Core.Link.Scaffolding.Server.Handlers;
using PCL.Core.Logging;
using System;
using System.Buffers;
using System.Buffers.Binary;
using System.Collections.Generic;
using System.IO;
using System.IO.Pipelines;
using System.Net;
using System.Net.Sockets;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
namespace PCL.Core.Link.Scaffolding.Server;
/// <summary>
/// A server for the Scaffolding data exchange protocol.
/// </summary>
public sealed class ScaffoldingServer : IAsyncDisposable
{
private readonly TcpListener _listener;
private readonly IServerContext _context;
private readonly Dictionary<string, IRequestHandler> _handlers;
private readonly CancellationTokenSource _cts = new();
private Task? _listenTask;
private Task? _cleanupTask;
private static readonly TimeSpan _PlayerTimeout = TimeSpan.FromSeconds(10);
private static readonly TimeSpan _CleanupInterval = TimeSpan.FromSeconds(5);
#region Events
public event Action<IReadOnlyList<PlayerProfile>>? ServerStarted;
public event Action? ServerStopped;
public event Action<Exception?>? ServerException;
public event Action<IReadOnlyList<PlayerProfile>>? PlayerProfilePing;
private void _OnContextPlayersPing(IReadOnlyList<PlayerProfile> players)
{
PlayerProfilePing?.Invoke(players);
}
#endregion
public ScaffoldingServer(int port, IServerContext context)
{
_listener = new TcpListener(IPAddress.Loopback, port);
_context = context;
_context.PlayerProfilesPing += _OnContextPlayersPing;
_handlers = new()
{
["c:player_ping"] = new PlayerPingHandler(),
["c:server_port"] = new GetServerPortHandler(),
["c:player_profiles_list"] = new GetPlayerProfileListHandler(),
["c:protocols"] = new GetProtocolsHandler(),
["c:ping"] = new PingHandler()
};
}
public void Start()
{
try
{
_listener.Start();
LogWrapper.Info("ScaffoldingServer",
$"Successfully bound to {_listener.LocalEndpoint}. Starting to accept clients.");
}
catch (SocketException ex)
{
LogWrapper.Error(ex, "ScaffoldingServer",
$"Failed to start TCP listener on port {((IPEndPoint)_listener.LocalEndpoint).Port}. The port might be in use or blocked.");
ServerException?.Invoke(ex);
return; // 启动失败,直接返回
}
_listenTask = _ListenForClientsAsync(_cts.Token);
_cleanupTask = _MonitorPlayerLivenessAsync(_cts.Token);
_listenTask.ContinueWith(t =>
{
LogWrapper.Error(t.Exception, "ScaffoldingServer",
"The main listening task failed unexpectedly. The server is no longer accepting new connections.");
ServerException?.Invoke(t.Exception);
}, TaskContinuationOptions.OnlyOnFaulted);
_cleanupTask.ContinueWith(
t =>
{
LogWrapper.Error(t.Exception, "ScaffoldingServer", "The player cleanup task failed unexpectedly.");
}, TaskContinuationOptions.OnlyOnFaulted);
LogWrapper.Debug("ScaffoldingServer", "Successfully scheduled server background tasks.");
ServerStarted?.Invoke(_context.PlayerProfiles);
}
private async Task _MonitorPlayerLivenessAsync(CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
try
{
await Task.Delay(_CleanupInterval, ct).ConfigureAwait(false);
var now = DateTime.UtcNow;
var timedOutPlayerKeys = new List<string>();
foreach (var (machineId, trackedPlayer) in _context.TrackedPlayers)
{
if (trackedPlayer.Profile.Kind is PlayerKind.HOST)
{
continue;
}
if (now - trackedPlayer.LastSeenUtc > _PlayerTimeout)
{
timedOutPlayerKeys.Add(machineId);
}
}
if (timedOutPlayerKeys.Count > 0)
{
var listChanged = false;
foreach (var key in timedOutPlayerKeys)
{
if (_context.TrackedPlayers.TryRemove(key, out var removedPlayer))
{
listChanged = true;
LogWrapper.Info("ScaffoldingServer",
$"Player '{removedPlayer.Profile.Name}' timed out and was removed.");
}
}
if (listChanged)
{
_context.OnPlayerProfilesChanged();
}
}
}
catch (OperationCanceledException)
{
break;
}
catch (Exception ex)
{
LogWrapper.Error(ex, "ScaffoldingServer", "An error occurred in the player cleanup task.");
}
}
}
private async Task _ListenForClientsAsync(CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
try
{
var tcpClient = await _listener.AcceptTcpClientAsync(ct).ConfigureAwait(false);
LogWrapper.Debug("ScaffoldingServer", $"Client connected: {tcpClient.Client.RemoteEndPoint}");
_ = _HandleClientAsync(tcpClient, ct);
}
catch (OperationCanceledException)
{
LogWrapper.Debug("ScaffoldingServer", "Listening task cancelled.");
break;
}
catch (Exception ex)
{
LogWrapper.Error(ex, "ScaffoldingServer", "Occurred an exception when server running.");
try
{
_listener.Stop();
}
catch (Exception lisEx)
{
LogWrapper.Error(lisEx, "ScaffoldingServer", "Occurred an exception when stop listening port.");
}
ServerStopped?.Invoke();
break;
}
LogWrapper.Debug("ScaffoldingServer", "Listening task finished.");
}
}
private async Task _HandleClientAsync(TcpClient tcpClient, CancellationToken ct)
{
var sessionId = Guid.NewGuid().ToString();
var clientEndPoint = tcpClient.Client.RemoteEndPoint?.ToString() ?? "unknown";
LogWrapper.Debug("ScaffoldingServer", $"New connection {sessionId} from {clientEndPoint}.");
using (tcpClient)
{
var stream = tcpClient.GetStream();
var reader = PipeReader.Create(stream);
var writer = PipeWriter.Create(stream);
try
{
while (!ct.IsCancellationRequested)
{
ReadResult readResult;
try
{
readResult = await reader.ReadAsync(ct).ConfigureAwait(false);
}
catch (IOException ex) when (ex.InnerException is SocketException se &&
se.SocketErrorCode == SocketError.ConnectionReset)
{
LogWrapper.Info("ScaffoldingServer",
$"Connection {sessionId} from {clientEndPoint} was closed by the client (Connection Reset).");
break;
}
var buffer = readResult.Buffer;
var consumedPosition = buffer.Start;
// REFACTOR: 这是核心修改。我们现在循环处理一个缓冲区,直到无法再解析出完整的帧。
while (_TryParseFrame(in buffer, out var requestFrame, out var frameEndPosition))
{
LogWrapper.Debug("ScaffoldingServer", $"[{sessionId}] Received frame: {requestFrame.TypeInfo}");
if (_handlers.TryGetValue(requestFrame.TypeInfo, out var handler))
{
var (status, responseBody) = await handler
.HandleAsync(requestFrame.Body, _context, sessionId, ct).ConfigureAwait(false);
var responseHeader = new byte[5];
responseHeader[0] = status;
BinaryPrimitives.WriteUInt32BigEndian(responseHeader.AsSpan(1), (uint)responseBody.Length);
await writer.WriteAsync(responseHeader, ct).ConfigureAwait(false);
if (responseBody.Length > 0)
{
await writer.WriteAsync(responseBody, ct).ConfigureAwait(false);
}
await writer.FlushAsync(ct).ConfigureAwait(false);
}
else
{
LogWrapper.Warn("ScaffoldingServer",
$"[{sessionId}] No handler for type: {requestFrame.TypeInfo}");
}
// 将缓冲区切片到已处理帧的末尾,为下一次循环做准备
consumedPosition = frameEndPosition;
buffer = buffer.Slice(consumedPosition);
}
// 告诉 PipeReader 我们已经检查了直到 readResult.Buffer.End 的所有数据,
// 并且我们已经处理了直到 consumedPosition 的数据。
reader.AdvanceTo(consumedPosition, buffer.End);
if (readResult.IsCompleted)
{
break;
}
}
}
catch (InvalidDataException ex)
{
LogWrapper.Warn("ScaffoldingServer",
$"Malformed packet from {clientEndPoint} on connection {sessionId}. Closing connection. Reason: {ex.Message}");
}
catch (OperationCanceledException)
{
LogWrapper.Debug("ScaffoldingServer", $"Connection {sessionId} was canceled.");
}
catch (Exception ex)
{
LogWrapper.Error(ex, "ScaffoldingServer", $"Unexpected error on connection {sessionId}.");
}
LogWrapper.Debug("ScaffoldingServer", $"Connection {sessionId} from {clientEndPoint} has ended.");
}
}
private static bool _TryParseFrame
(in ReadOnlySequence<byte> buffer, out (string TypeInfo, byte[] Body) frame, out SequencePosition consumed)
{
frame = default;
consumed = buffer.Start;
const int maxTypeLength = 128;
const int maxBodyLength = 65536;
var reader = new SequenceReader<byte>(buffer);
// 检查头部是否完整 (1字节类型长度 + 4字节内容长度)
if (buffer.Length < 1) return false;
if (!reader.TryRead(out var typeLength)) return false;
if (typeLength is 0 or > maxTypeLength)
throw new InvalidDataException($"Invalid frame type length: {typeLength}.");
if (reader.Remaining < typeLength + 4) return false;
// 读取类型信息
Span<byte> typeInfoSpan = stackalloc byte[typeLength];
if (!reader.TryCopyTo(typeInfoSpan)) return false;
reader.Advance(typeLength);
var typeInfo = Encoding.UTF8.GetString(typeInfoSpan);
// 读取内容长度
if (!reader.TryReadBigEndian(out int bodyLength32)) return false;
var bodyLength = (uint)bodyLength32;
if (bodyLength > maxBodyLength)
throw new InvalidDataException($"Frame body length {bodyLength} exceeds maximum of {maxBodyLength}.");
// 检查内容是否完整
if (reader.Remaining < bodyLength) return false;
// 提取内容
var bodyBuffer = reader.Sequence.Slice(reader.Position, bodyLength);
var body = bodyBuffer.ToArray();
// 构造帧
frame = (typeInfo, body);
reader.Advance(bodyLength);
consumed = reader.Position;
return true;
}
/// <inheritdoc />
public async ValueTask DisposeAsync()
{
LogWrapper.Debug("ScaffoldingServer", "Come into DisposeAsync().");
if (!_cts.IsCancellationRequested)
{
await _cts.CancelAsync().ConfigureAwait(false);
}
if (_listenTask is not null)
{
try
{
await _listenTask.ConfigureAwait(false);
}
catch (OperationCanceledException)
{
// ignore
}
catch (Exception ex)
{
LogWrapper.Error(ex, "ScaffoldingServer",
"An exception occurred while awaiting the listen task during disposal.");
}
}
if (_cleanupTask is not null)
{
try
{
await _cleanupTask.ConfigureAwait(false);
}
catch (OperationCanceledException)
{
// ignore
}
catch (Exception ex)
{
LogWrapper.Error(ex, "ScaffoldingServer",
"An exception occurred while awaiting the cleanup task during disposal.");
}
}
_listener.Stop();
_cts.Dispose();
LogWrapper.Debug("ScaffoldingServer", "Server and all background tasks stopped gracefully.");
ServerStopped?.Invoke();
}
}
@@ -0,0 +1,86 @@
using PCL.Core.Link.Scaffolding.Client.Models;
using PCL.Core.Link.Scaffolding.EasyTier;
using PCL.Core.Link.Scaffolding.Server.Abstractions;
using PCL.Core.App;
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
namespace PCL.Core.Link.Scaffolding.Server;
/// <summary>
/// Scaffolding Server running context.
/// </summary>
public class ScaffoldingServerContext : IServerContext
{
private readonly ConcurrentDictionary<string, TrackedPlayerProfile> _trackedPlayers = [];
/// <inheritdoc />
public ConcurrentDictionary<string, TrackedPlayerProfile> TrackedPlayers
{
get => _trackedPlayers;
private init => _trackedPlayers = value;
}
public IReadOnlyList<PlayerProfile> PlayerProfiles =>
_trackedPlayers.Values.Select(player => player.Profile).ToList().AsReadOnly();
/// <inheritdoc />
public event Action<IReadOnlyList<PlayerProfile>>? PlayerProfilesPing;
public void OnPlayerProfilesChanged()
{
var currentProfiles = PlayerProfiles;
Task.Run(() => PlayerProfilesPing?.Invoke(currentProfiles));
}
/// <inheritdoc />
public int MinecraftServerProt { get; }
/// <inheritdoc />
public LobbyInfo UserLobbyInfo { get; }
/// <inheritdoc />
public string PlayerName { get; }
private ScaffoldingServerContext(
ConcurrentDictionary<string, TrackedPlayerProfile> profiles,
int mcPort,
LobbyInfo info,
string playerName)
{
TrackedPlayers = profiles;
MinecraftServerProt = mcPort;
UserLobbyInfo = info;
PlayerName = playerName;
}
/// <summary>
/// Create a <see cref="ScaffoldingServerContext"/>.
/// </summary>
/// <param name="playerName">Player name.</param>
/// <param name="mcPort">Minecraft shared port.</param>
public static ScaffoldingServerContext Create(string playerName, int mcPort)
{
var machineId = Utils.Secret.Identify.LauncherId;
var profile = new PlayerProfile
{
Name = playerName,
MachineId = Utils.Secret.Identify.LauncherId,
// Please update ScaffoldingFactory.cs at the same time.
Vendor = $"PCL CE {Basics.VersionName}, EasyTier {EasyTierMetadata.CurrentEasyTierVer}",
Kind = PlayerKind.HOST
};
var tracked = new TrackedPlayerProfile { Profile = profile, LastSeenUtc = DateTime.UtcNow };
var roomCode = LobbyCodeGenerator.Generate();
var profiles = new ConcurrentDictionary<string, TrackedPlayerProfile>();
profiles.TryAdd(machineId, tracked);
return new ScaffoldingServerContext(profiles, mcPort, roomCode, playerName);
}
}
@@ -0,0 +1,10 @@
using PCL.Core.Link.Scaffolding.Client.Models;
using System;
namespace PCL.Core.Link.Scaffolding.Server;
public record TrackedPlayerProfile
{
public required PlayerProfile Profile { get; set; }
public required DateTime LastSeenUtc { get; set; }
};