Files
2026-07-10 10:24:29 +08:00

434 lines
18 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System;
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)));
}
}
}