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,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; }
};