Initial commit: Client Doc docs Server Tools

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
ud18010
2026-07-10 10:24:29 +08:00
co-authored by Cursor
commit 7e35d8da31
3374 changed files with 680813 additions and 0 deletions
+433
View File
@@ -0,0 +1,433 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using ChannelsNS = System.Threading.Channels;
using XWorld.Framework;
using XWorld.Framework.Protocol;
using XWorld.Server.Host;
namespace XWorld.Server.Gateway
{
// 单线程 actor:独占 RoomHost 与 SessionManager;命令入队、周期性排空+推进。
public sealed class ServerLoop
{
private readonly RoomHost _host;
private readonly SessionManager _sessions = new SessionManager();
private readonly ILogger _logger;
private readonly long _reconnectWindowTicks;
private readonly string _gamesRoot;
private readonly Matchmaker _matchmaker;
private readonly ChannelsNS.Channel<ServerCommand> _cmds =
ChannelsNS.Channel.CreateUnbounded<ServerCommand>();
private readonly Dictionary<string, List<int>> _roomPlayers = new Dictionary<string, List<int>>();
private readonly Dictionary<int, LobbyPlayerMsg> _lobbyPlayers = new Dictionary<int, LobbyPlayerMsg>();
private readonly Dictionary<string, int> _playerCountCache = new Dictionary<string, int>(); // "gameId@version" -> playerCount
private readonly HashSet<int> _queuedPids = new HashSet<int>(); // pids currently in matchmaker queue
private readonly Random _random = new Random(1);
private long _tick;
private int _roomSeq;
public ServerLoop(string gamesRoot, ILogger logger, long reconnectWindowTicks = 100,
long matchTimeoutTicks = 150, string publicKeyPem = null)
{
_logger = logger;
_reconnectWindowTicks = reconnectWindowTicks;
_gamesRoot = gamesRoot;
_host = new RoomHost(new GameModuleRegistry(new GameModuleLoader(publicKeyPem), gamesRoot), logger);
_matchmaker = new Matchmaker(matchTimeoutTicks);
}
// 线程安全:网络线程调用,仅入队
public void Submit(ServerCommand cmd) => _cmds.Writer.TryWrite(cmd);
// 仅 actor 线程调用(_sessions 无锁);测试在单线程下使用
public bool HasSession(int pid) => _sessions.Get(pid) != null;
// 测试直调;真实循环周期性调它。
// 两步排空:先处理所有 Connect/Disconnect/Framework(含 MatchRequest 入队),
// 再 Poll 匹配器建房,再处理 Game 帧(此时 RoomId 已就位)。
public void DrainAndTick(float dt)
{
// 第一遍:收集所有命令,立刻处理非游戏帧(connect/disconnect/framework
var deferred = new List<FrameCommand>(); // 本批游戏帧,延后到建房后处理
while (_cmds.Reader.TryRead(out var cmd))
{
if (cmd is FrameCommand fc && fc.Frame.Channel == Channel.Game)
deferred.Add(fc);
else
Process(cmd);
}
// Poll 匹配器 → 对本 tick 内已凑齐或超时的队列建房
var matches = _matchmaker.Poll(_tick);
for (int i = 0; i < matches.Count; i++)
{
_logger.Info($"match formed {matches[i].GameId}@{matches[i].Version} seats={matches[i].Seats.Count} tick={_tick}");
CreateRoomForSeats(matches[i]);
}
// 第二遍:处理本批游戏帧(此时 RoomId 已就位)
for (int i = 0; i < deferred.Count; i++)
{
var fc = deferred[i];
Route(fc.PlayerId, fc.Frame);
}
_tick++;
var ended = _host.TickAll(dt);
for (int i = 0; i < ended.Count; i++) OnRoomEnded(ended[i].RoomId, ended[i].Result);
var expired = _sessions.SweepExpired(_tick, _reconnectWindowTicks);
for (int i = 0; i < expired.Count; i++)
_logger.Info($"session {expired[i].PlayerId} 重连窗口过期,清除");
}
public async Task RunAsync(int tickIntervalMs, CancellationToken ct)
=> await RunAsync(tickIntervalMs, tickIntervalMs / 1000f, ct);
public async Task RunAsync(int tickIntervalMs, float logicalDt, CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
DrainAndTick(logicalDt);
try { await Task.Delay(tickIntervalMs, ct).ConfigureAwait(false); }
catch (OperationCanceledException) { break; }
}
}
private void Process(ServerCommand cmd)
{
switch (cmd)
{
case ConnectCommand cc:
_sessions.OnConnect(cc.PlayerId, cc.Connection);
_logger.Info($"session connect pid={cc.PlayerId}");
break;
case DisconnectCommand dc:
_sessions.OnDisconnect(dc.PlayerId, dc.Connection, _tick);
_logger.Info($"session disconnect pid={dc.PlayerId}");
if (_queuedPids.Remove(dc.PlayerId)) _matchmaker.Remove(dc.PlayerId);
if (_lobbyPlayers.Remove(dc.PlayerId)) BroadcastLobbyState();
break;
case FrameCommand fc: Route(fc.PlayerId, fc.Frame); break;
}
}
private void Route(int pid, Frame frame)
{
if (frame.Channel == Channel.Framework) HandleFramework(pid, frame);
else
{
var s = _sessions.Get(pid);
if (s?.RoomId != null)
_host.DeliverTo(s.RoomId, pid, new NetMessage(frame.Opcode, frame.Payload));
}
}
private void HandleFramework(int pid, Frame frame)
{
FrameworkOpcode op = (FrameworkOpcode)frame.Opcode;
if (op != FrameworkOpcode.LobbyMove)
{
_logger.Info($"framework pid={pid} op={op} bytes={frame.Payload?.Length ?? 0}");
}
switch (op)
{
case FrameworkOpcode.Heartbeat:
SendFramework(pid, FrameworkOpcode.Heartbeat, frame.Payload); // 原样回显
break;
case FrameworkOpcode.MatchRequest:
var req = MatchRequestMsg.Decode(frame.Payload);
EnqueueMatch(pid, req.GameId, req.Version);
break;
case FrameworkOpcode.GameListRequest:
SendFramework(pid, FrameworkOpcode.GameListResponse, BuildGameList().Encode());
break;
case FrameworkOpcode.RandomMatchRequest:
EnqueueRandomMatch(pid);
break;
case FrameworkOpcode.LobbyJoin:
HandleLobbyJoin(pid, frame.Payload);
break;
case FrameworkOpcode.LobbyMove:
HandleLobbyMove(pid, frame.Payload);
break;
case FrameworkOpcode.LobbyLeave:
if (_lobbyPlayers.Remove(pid)) BroadcastLobbyState();
break;
}
}
private void HandleLobbyJoin(int pid, byte[] payload)
{
var req = LobbyJoinMsg.Decode(payload);
string name = string.IsNullOrWhiteSpace(req.Name) ? $"P{pid}" : req.Name;
int figure = req.Figure <= 0 ? 1 : req.Figure;
if (!_lobbyPlayers.TryGetValue(pid, out var player))
{
float lane = (_lobbyPlayers.Count % 6) - 2.5f;
player = new LobbyPlayerMsg
{
PlayerId = pid,
Name = name,
Figure = figure,
X = lane * 1.5f,
Y = 0f,
Z = 0f,
DirX = 0f,
DirZ = 1f,
Moving = false
};
_lobbyPlayers[pid] = player;
}
else
{
player.Name = name;
player.Figure = figure;
}
_logger.Info($"lobby join pid={pid} name={name} figure={figure} players={_lobbyPlayers.Count} pids={string.Join(",", _lobbyPlayers.Keys)}");
BroadcastLobbyState();
}
private void HandleLobbyMove(int pid, byte[] payload)
{
if (!_lobbyPlayers.TryGetValue(pid, out var player))
{
_logger.Warn($"lobby move ignored pid={pid} reason=not_joined players={_lobbyPlayers.Count} pids={string.Join(",", _lobbyPlayers.Keys)}");
return;
}
var move = LobbyMoveMsg.Decode(payload);
player.X = move.X;
player.Y = move.Y;
player.Z = move.Z;
player.DirX = move.DirX;
player.DirZ = move.DirZ;
player.Moving = move.Moving;
move.PlayerId = pid;
byte[] encoded = move.Encode();
foreach (int targetPid in _lobbyPlayers.Keys)
{
SendFramework(targetPid, FrameworkOpcode.LobbyMove, encoded);
}
}
private void BroadcastLobbyState()
{
if (_lobbyPlayers.Count == 0) return;
var state = new LobbyStateMsg();
foreach (var player in _lobbyPlayers.Values)
{
state.Players.Add(player);
}
byte[] payload = state.Encode();
_logger.Info($"lobby state players={_lobbyPlayers.Count} pids={string.Join(",", _lobbyPlayers.Keys)}");
foreach (int pid in _lobbyPlayers.Keys)
{
SendFramework(pid, FrameworkOpcode.LobbyState, payload);
}
}
private void EnqueueRandomMatch(int pid)
{
var games = BuildGameList().Games;
if (games.Count == 0)
{
SendFramework(pid, FrameworkOpcode.Error,
new ErrorMsg { Code = 1, Message = "no minigame available" }.Encode());
return;
}
var selected = games[_random.Next(games.Count)];
_logger.Info($"random match pid={pid} assigned {selected.GameId}@{selected.Version}");
SendFramework(pid, FrameworkOpcode.MatchAssigned,
new MatchAssignedMsg { GameId = selected.GameId, Version = selected.Version }.Encode());
EnqueueMatch(pid, selected.GameId, selected.Version);
}
private GameListResponseMsg BuildGameList()
{
var latest = new Dictionary<string, GameInfoMsg>(StringComparer.OrdinalIgnoreCase);
if (!Directory.Exists(_gamesRoot)) return new GameListResponseMsg();
foreach (var gameDir in Directory.GetDirectories(_gamesRoot))
{
foreach (var versionDir in Directory.GetDirectories(gameDir))
{
string manifestPath = Path.Combine(versionDir, "game.json");
if (!File.Exists(manifestPath)) continue;
try
{
var manifest = GameManifest.Load(manifestPath);
if (string.Equals(manifest.GameId, "lobby", StringComparison.OrdinalIgnoreCase)) continue;
if (manifest.PlayerCount < 2 || manifest.PlayerCount > 8) continue;
if (!latest.TryGetValue(manifest.GameId, out var old) || manifest.Version > old.Version)
{
latest[manifest.GameId] = new GameInfoMsg
{
GameId = manifest.GameId,
Version = manifest.Version,
DisplayName = manifest.GameId,
MinPlayers = 2,
MaxPlayers = Math.Max(2, Math.Min(8, manifest.PlayerCount)),
};
}
}
catch (Exception ex)
{
_logger.Warn($"skip invalid game manifest {manifestPath}: {ex.Message}");
}
}
}
var msg = new GameListResponseMsg();
foreach (var game in latest.Values) msg.Games.Add(game);
msg.Games.Sort((a, b) => string.Compare(a.GameId, b.GameId, StringComparison.OrdinalIgnoreCase));
return msg;
}
private void EnqueueMatch(int pid, string gameId, int version)
{
var s = _sessions.Get(pid);
if (s == null) return; // 未建立会话,忽略
if (s.RoomId != null || _queuedPids.Contains(pid))
{
// 已在房间内或已在队列中:拒绝
SendFramework(pid, FrameworkOpcode.Error,
new ErrorMsg { Code = 2, Message = "already in room or queue" }.Encode());
return;
}
// 读取游戏的 playerCount(缓存)
string cacheKey = $"{gameId}@{version}";
if (!_playerCountCache.TryGetValue(cacheKey, out int playerCount))
{
string manifestPath = Path.Combine(_gamesRoot, gameId, version.ToString(), "game.json");
try
{
var manifest = GameManifest.Load(manifestPath);
playerCount = manifest.PlayerCount;
_playerCountCache[cacheKey] = playerCount;
}
catch (Exception ex)
{
_logger.Error($"读取 game.json 失败 {gameId}@{version}: {ex.Message}");
SendFramework(pid, FrameworkOpcode.Error,
new ErrorMsg { Code = 1, Message = "game not found" }.Encode());
return;
}
}
_queuedPids.Add(pid);
_matchmaker.Enqueue(pid, gameId, version, playerCount, _tick);
_logger.Info($"enqueue match pid={pid} game={gameId}@{version} playerCount={playerCount} tick={_tick}");
}
private void CreateRoomForSeats(Matchmaker.Match match)
{
string roomId = $"r{++_roomSeq}";
var seats = match.Seats;
int aiCount = 0;
for (int i = 0; i < seats.Count; i++)
{
if (seats[i].IsAi) aiCount++;
}
_logger.Info($"create room {roomId} game={match.GameId}@{match.Version} real={seats.Count - aiCount} ai={aiCount}");
// Build PlayerInfo[] for all seats
var playerInfos = new PlayerInfo[seats.Count];
var realPids = new List<int>();
for (int i = 0; i < seats.Count; i++)
{
var seat = seats[i];
playerInfos[i] = new PlayerInfo
{
PlayerId = seat.PlayerId,
Name = seat.IsAi ? "AI" : $"P{seat.PlayerId}",
IsAI = seat.IsAi
};
if (!seat.IsAi)
{
realPids.Add(seat.PlayerId);
_queuedPids.Remove(seat.PlayerId);
}
}
var sink = new RoomOutputSink(roomId, realPids, _sessions);
var cfg = new RoomConfig
{
GameId = match.GameId,
Version = match.Version,
Seed = (ulong)_roomSeq,
PlayerCount = seats.Count
};
Room room;
try
{
room = _host.CreateRoom(roomId, match.GameId, match.Version, cfg, playerInfos, sink);
}
catch (Exception ex)
{
_logger.Error($"建房失败 {match.GameId}@{match.Version}: {ex.Message}");
// Notify real players of error
var errPayload = new ErrorMsg { Code = 1, Message = "create room failed" }.Encode();
foreach (int rpid in realPids)
SendFramework(rpid, FrameworkOpcode.Error, errPayload);
return;
}
// Build MatchFound message with all players info
var allPlayers = new List<PlayerInfo>(playerInfos);
// For each real player: set session.RoomId + send MatchFound
foreach (int rpid in realPids)
{
var s = _sessions.Get(rpid);
if (s != null) s.RoomId = roomId;
var found = new MatchFoundMsg
{
RoomId = roomId,
Version = match.Version,
SelfPlayerId = rpid
};
foreach (var pi in allPlayers) found.Players.Add(pi);
SendFramework(rpid, FrameworkOpcode.MatchFound, found.Encode());
}
_roomPlayers[roomId] = realPids;
if (!room.IsActive) OnRoomEnded(roomId, room.TrustedEndResult); // 极端:Start 即结束
}
private void OnRoomEnded(string roomId, RoomEndResult result)
{
if (!_roomPlayers.TryGetValue(roomId, out var players)) return;
_roomPlayers.Remove(roomId);
var msg = new RoomEndMsg
{
WinnerPlayerId = result?.WinnerPlayerId ?? 0,
ResultBlob = result?.ResultBlob ?? Array.Empty<byte>()
};
byte[] payload = msg.Encode();
for (int i = 0; i < players.Count; i++)
{
int pid = players[i];
SendFramework(pid, FrameworkOpcode.RoomEnd, payload);
var s = _sessions.Get(pid);
if (s != null) s.RoomId = null;
}
}
private void SendFramework(int pid, FrameworkOpcode op, byte[] payload)
{
_sessions.GetConnection(pid)?.Send(
FrameCodec.Encode(new Frame(Channel.Framework, (ushort)op, payload)));
}
}
}