Initial commit: Client Doc docs Server Tools
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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)));
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user