fix: improve minigame reconnect flow
This commit is contained in:
@@ -18,10 +18,11 @@ namespace XWorld.Framework.Tests
|
||||
[Fact]
|
||||
public void MatchRequest_RoundTrips()
|
||||
{
|
||||
var m = new MatchRequestMsg { GameId = "rock_paper_scissors", Version = 3 };
|
||||
var m = new MatchRequestMsg { GameId = "rock_paper_scissors", Version = 3, RequestId = 42 };
|
||||
var d = MatchRequestMsg.Decode(m.Encode());
|
||||
Assert.Equal(m.GameId, d.GameId);
|
||||
Assert.Equal(m.Version, d.Version);
|
||||
Assert.Equal(m.RequestId, d.RequestId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -29,9 +30,11 @@ namespace XWorld.Framework.Tests
|
||||
{
|
||||
var m = new MatchFoundMsg
|
||||
{
|
||||
GameId = "rps",
|
||||
RoomId = "room-42",
|
||||
Version = 3,
|
||||
SelfPlayerId = 1001,
|
||||
RequestId = 99,
|
||||
Players =
|
||||
{
|
||||
new PlayerInfo
|
||||
@@ -55,9 +58,11 @@ namespace XWorld.Framework.Tests
|
||||
},
|
||||
};
|
||||
var d = MatchFoundMsg.Decode(m.Encode());
|
||||
Assert.Equal("rps", d.GameId);
|
||||
Assert.Equal("room-42", d.RoomId);
|
||||
Assert.Equal(3, d.Version);
|
||||
Assert.Equal(1001, d.SelfPlayerId);
|
||||
Assert.Equal(99, d.RequestId);
|
||||
Assert.Equal(2, d.Players.Count);
|
||||
Assert.Equal(1001, d.Players[0].PlayerId);
|
||||
Assert.Equal("Alice", d.Players[0].Name);
|
||||
@@ -101,10 +106,30 @@ namespace XWorld.Framework.Tests
|
||||
[Fact]
|
||||
public void Error_RoundTrips()
|
||||
{
|
||||
var m = new ErrorMsg { Code = 404, Message = "version mismatch" };
|
||||
var m = new ErrorMsg { Code = 404, Message = "version mismatch", RequestId = 7 };
|
||||
var d = ErrorMsg.Decode(m.Encode());
|
||||
Assert.Equal(404, d.Code);
|
||||
Assert.Equal("version mismatch", d.Message);
|
||||
Assert.Equal(7, d.RequestId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MatchControl_RoundTrips()
|
||||
{
|
||||
var m = new MatchControlMsg { RequestId = 123 };
|
||||
var d = MatchControlMsg.Decode(m.Encode());
|
||||
Assert.Equal(123, d.RequestId);
|
||||
Assert.Equal(0, MatchControlMsg.Decode(System.Array.Empty<byte>()).RequestId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MatchAssigned_RoundTrips()
|
||||
{
|
||||
var m = new MatchAssignedMsg { GameId = "rps", Version = 2, RequestId = 88 };
|
||||
var d = MatchAssignedMsg.Decode(m.Encode());
|
||||
Assert.Equal("rps", d.GameId);
|
||||
Assert.Equal(2, d.Version);
|
||||
Assert.Equal(88, d.RequestId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -141,6 +166,7 @@ namespace XWorld.Framework.Tests
|
||||
Assert.Equal((ushort)7, (ushort)FrameworkOpcode.Error);
|
||||
Assert.Equal((ushort)16, (ushort)FrameworkOpcode.WorldChatSend);
|
||||
Assert.Equal((ushort)17, (ushort)FrameworkOpcode.WorldChatMessage);
|
||||
Assert.Equal((ushort)18, (ushort)FrameworkOpcode.MatchCancel);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -209,6 +235,7 @@ namespace XWorld.Framework.Tests
|
||||
MatchFoundMsg d = MatchFoundMsg.Decode(w.ToArray());
|
||||
|
||||
Assert.Equal("legacy-room", d.RoomId);
|
||||
Assert.Equal(0, d.RequestId);
|
||||
Assert.Equal(2, d.Players.Count);
|
||||
Assert.Equal(1001, d.Players[0].PlayerId);
|
||||
Assert.Equal("LegacyAlice", d.Players[0].Name);
|
||||
|
||||
@@ -14,6 +14,9 @@ namespace XWorld.Server.Gateway.Runner
|
||||
Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "Server.Host.Tests", "TestGames"));
|
||||
int port = int.TryParse(GetArg(args, "--port"), out int p) ? p : 5005;
|
||||
int tickMs = int.TryParse(GetArg(args, "--tickMs"), out int t) ? t : 100;
|
||||
long reconnectWindowTicks = long.TryParse(GetArg(args, "--reconnectWindowTicks"), out long rw)
|
||||
? rw
|
||||
: ServerLoop.DefaultReconnectWindowTicks;
|
||||
long matchTimeoutTicks = long.TryParse(GetArg(args, "--matchTimeoutTicks"), out long mt) ? mt : 150;
|
||||
string devToken = GetArg(args, "--devToken");
|
||||
string advertiseWs = GetArg(args, "--advertiseWs");
|
||||
@@ -49,13 +52,15 @@ namespace XWorld.Server.Gateway.Runner
|
||||
}
|
||||
|
||||
var host = new GameServerHost();
|
||||
await host.StartAsync(gamesRoot, tickMs, matchTimeoutTicks: matchTimeoutTicks, listenPort: port,
|
||||
await host.StartAsync(gamesRoot, tickMs, reconnectWindowTicks: reconnectWindowTicks,
|
||||
matchTimeoutTicks: matchTimeoutTicks, listenPort: port,
|
||||
bindAllInterfaces: lan,
|
||||
devDiscoveryToken: devToken, advertiseGatewayUrl: advertiseWs, advertiseResourceBaseUrl: advertiseCdn,
|
||||
authSecret: authSecret, authDbPath: authDbPath);
|
||||
Console.WriteLine("XWorld Gateway smoke server started.");
|
||||
Console.WriteLine(" gamesRoot: " + gamesRoot);
|
||||
Console.WriteLine(" tickMs: " + tickMs + ", matchTimeoutTicks: " + matchTimeoutTicks);
|
||||
Console.WriteLine(" tickMs: " + tickMs + ", reconnectWindowTicks: " + reconnectWindowTicks +
|
||||
", matchTimeoutTicks: " + matchTimeoutTicks);
|
||||
Console.WriteLine(" ws: " + host.WsBaseUrl + "/ws?pid=1");
|
||||
if (!string.IsNullOrEmpty(devToken))
|
||||
Console.WriteLine(" dev-discovery: ON (UDP " + host.DevDiscoveryPort + "), advertise ws=" +
|
||||
|
||||
@@ -192,6 +192,67 @@ namespace XWorld.Server.Gateway.Tests
|
||||
Assert.Contains(Frames(c), f => f.Channel == Channel.Framework && f.Opcode == (ushort)FrameworkOpcode.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MatchCancel_AfterRoomCreated_ClearsRoomAndAllowsPlayerToMatchAgain()
|
||||
{
|
||||
var loop = NewLoop(matchTimeoutTicks: 0);
|
||||
var c = new FakeConnection(5);
|
||||
loop.Submit(new ConnectCommand { PlayerId = 5, Connection = c });
|
||||
loop.Submit(new FrameCommand { PlayerId = 5, Frame = FrameworkFrame(FrameworkOpcode.RandomMatchRequest) });
|
||||
loop.DrainAndTick(1f);
|
||||
|
||||
Assert.True(HasMatchFound(c), "precondition: player should be in a room before cancel");
|
||||
|
||||
c.Sent.Clear();
|
||||
loop.Submit(new FrameCommand { PlayerId = 5, Frame = FrameworkFrame(FrameworkOpcode.MatchCancel) });
|
||||
loop.DrainAndTick(1f);
|
||||
|
||||
loop.Submit(new FrameCommand { PlayerId = 5, Frame = FrameworkFrame(FrameworkOpcode.RandomMatchRequest) });
|
||||
loop.DrainAndTick(1f);
|
||||
|
||||
Assert.Contains(Frames(c), f => f.Channel == Channel.Framework && f.Opcode == (ushort)FrameworkOpcode.MatchAssigned);
|
||||
Assert.DoesNotContain(Frames(c), f =>
|
||||
f.Channel == Channel.Framework &&
|
||||
f.Opcode == (ushort)FrameworkOpcode.Error &&
|
||||
ErrorMsg.Decode(f.Payload).Message == "already in room or queue");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MatchCancel_InTwoPlayerRoom_ClearsRoomForAllPlayers()
|
||||
{
|
||||
var loop = NewLoop(matchTimeoutTicks: 100);
|
||||
var c1 = new FakeConnection(1);
|
||||
var c2 = new FakeConnection(2);
|
||||
loop.Submit(new ConnectCommand { PlayerId = 1, Connection = c1 });
|
||||
loop.Submit(new ConnectCommand { PlayerId = 2, Connection = c2 });
|
||||
loop.Submit(new FrameCommand { PlayerId = 1, Frame = FrameCodec.Decode(RpsJoinFrame()) });
|
||||
loop.Submit(new FrameCommand { PlayerId = 2, Frame = FrameCodec.Decode(RpsJoinFrame()) });
|
||||
loop.DrainAndTick(1f);
|
||||
|
||||
Assert.True(HasMatchFound(c1), "precondition: player 1 should be in a room before cancel");
|
||||
Assert.True(HasMatchFound(c2), "precondition: player 2 should be in a room before cancel");
|
||||
|
||||
c1.Sent.Clear();
|
||||
c2.Sent.Clear();
|
||||
loop.Submit(new FrameCommand { PlayerId = 1, Frame = FrameworkFrame(FrameworkOpcode.MatchCancel) });
|
||||
loop.DrainAndTick(1f);
|
||||
|
||||
loop.Submit(new FrameCommand { PlayerId = 1, Frame = FrameCodec.Decode(RpsJoinFrame()) });
|
||||
loop.Submit(new FrameCommand { PlayerId = 2, Frame = FrameCodec.Decode(RpsJoinFrame()) });
|
||||
loop.DrainAndTick(1f);
|
||||
|
||||
Assert.True(HasMatchFound(c1), "player 1 should be able to match again after cancel");
|
||||
Assert.True(HasMatchFound(c2), "player 2 should be able to match again after the room was cancelled");
|
||||
Assert.DoesNotContain(Frames(c1), f =>
|
||||
f.Channel == Channel.Framework &&
|
||||
f.Opcode == (ushort)FrameworkOpcode.Error &&
|
||||
ErrorMsg.Decode(f.Payload).Message == "already in room or queue");
|
||||
Assert.DoesNotContain(Frames(c2), f =>
|
||||
f.Channel == Channel.Framework &&
|
||||
f.Opcode == (ushort)FrameworkOpcode.Error &&
|
||||
ErrorMsg.Decode(f.Payload).Message == "already in room or queue");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Player 1 disconnects while still queued (before match forms).
|
||||
/// DrainAndTick processes the DisconnectCommand (which dequeues pid=1) BEFORE Poll,
|
||||
|
||||
@@ -140,6 +140,42 @@ namespace XWorld.Server.Gateway.Tests
|
||||
Assert.Contains(Frames(c1b), f => f.Channel == Channel.Game);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reconnect_ReplaysMatchFoundAndLatestGameSnapshot()
|
||||
{
|
||||
var loop = NewLoop(reconnectWindow: 100);
|
||||
var c1 = new FakeConnection(1);
|
||||
var c2 = new FakeConnection(2);
|
||||
loop.Submit(new ConnectCommand { PlayerId = 1, Connection = c1 });
|
||||
loop.Submit(new ConnectCommand { PlayerId = 2, Connection = c2 });
|
||||
loop.Submit(new FrameCommand { PlayerId = 1, Frame = FrameCodec.Decode(JoinFrame("rps", 1)) });
|
||||
loop.Submit(new FrameCommand { PlayerId = 2, Frame = FrameCodec.Decode(JoinFrame("rps", 1)) });
|
||||
loop.DrainAndTick(1f);
|
||||
|
||||
loop.Submit(new DisconnectCommand { PlayerId = 1, Connection = c1 });
|
||||
loop.DrainAndTick(1f);
|
||||
|
||||
var c1b = new FakeConnection(1);
|
||||
loop.Submit(new ConnectCommand { PlayerId = 1, Connection = c1b });
|
||||
loop.DrainAndTick(0f);
|
||||
|
||||
Frame resumeFrame = Frames(c1b).Find(f =>
|
||||
f.Channel == Channel.Framework && f.Opcode == (ushort)FrameworkOpcode.MatchFound);
|
||||
Assert.NotEqual(default, resumeFrame);
|
||||
MatchFoundMsg resume = MatchFoundMsg.Decode(resumeFrame.Payload);
|
||||
Assert.Equal("rps", resume.GameId);
|
||||
Assert.Equal(1, resume.Version);
|
||||
Assert.Equal(1, resume.SelfPlayerId);
|
||||
Assert.Equal(2, resume.Players.Count);
|
||||
Assert.Contains(Frames(c1b), f => f.Channel == Channel.Game);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DefaultReconnectWindow_IsThreeMinutesAtDefaultTickRate()
|
||||
{
|
||||
Assert.Equal(1800, ServerLoop.DefaultReconnectWindowTicks);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExpiredSession_IsSwept()
|
||||
{
|
||||
@@ -173,6 +209,27 @@ namespace XWorld.Server.Gateway.Tests
|
||||
Assert.Contains(Frames(c1), f => f.Channel == Channel.Framework && f.Opcode == (ushort)FrameworkOpcode.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MatchCancel_WhileQueued_CancelsQueuedMatchAndAllowsPlayerToMatchAgain()
|
||||
{
|
||||
var loop = NewLoop();
|
||||
var c = new FakeConnection(1);
|
||||
loop.Submit(new ConnectCommand { PlayerId = 1, Connection = c });
|
||||
loop.Submit(new FrameCommand { PlayerId = 1, Frame = FrameCodec.Decode(JoinFrame("rps", 1)) });
|
||||
loop.DrainAndTick(1f);
|
||||
|
||||
c.Sent.Clear();
|
||||
loop.Submit(new FrameCommand { PlayerId = 1, Frame = new Frame(Channel.Framework, (ushort)FrameworkOpcode.MatchCancel, System.Array.Empty<byte>()) });
|
||||
loop.Submit(new FrameCommand { PlayerId = 1, Frame = new Frame(Channel.Framework, (ushort)FrameworkOpcode.RandomMatchRequest, System.Array.Empty<byte>()) });
|
||||
loop.DrainAndTick(1f);
|
||||
|
||||
Assert.Contains(Frames(c), f => f.Channel == Channel.Framework && f.Opcode == (ushort)FrameworkOpcode.MatchAssigned);
|
||||
Assert.DoesNotContain(Frames(c), f =>
|
||||
f.Channel == Channel.Framework &&
|
||||
f.Opcode == (ushort)FrameworkOpcode.Error &&
|
||||
ErrorMsg.Decode(f.Payload).Message == "already in room or queue");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GameFrame_WithNoRoom_IsIgnoredSafely()
|
||||
{
|
||||
|
||||
@@ -31,7 +31,7 @@ namespace XWorld.Server.Gateway
|
||||
public string WsBaseUrl { get; private set; } // 例如 ws://127.0.0.1:54321
|
||||
public int DevDiscoveryPort => _discovery?.Port ?? 0;
|
||||
|
||||
public async Task StartAsync(string gamesRoot, int tickIntervalMs, long reconnectWindowTicks = 100,
|
||||
public async Task StartAsync(string gamesRoot, int tickIntervalMs, long reconnectWindowTicks = ServerLoop.DefaultReconnectWindowTicks,
|
||||
long matchTimeoutTicks = 150, float? logicalDt = null, string publicKeyPem = null, int listenPort = 0,
|
||||
bool bindAllInterfaces = false,
|
||||
string devDiscoveryToken = null, string advertiseGatewayUrl = null, string advertiseResourceBaseUrl = null,
|
||||
|
||||
@@ -4,10 +4,10 @@ namespace XWorld.Server.Gateway
|
||||
{
|
||||
public sealed class Matchmaker
|
||||
{
|
||||
public sealed class Seat { public int PlayerId; public bool IsAi; }
|
||||
public sealed class Seat { public int PlayerId; public bool IsAi; public int RequestId; }
|
||||
public sealed class Match { public string GameId; public int Version; public List<Seat> Seats = new List<Seat>(); }
|
||||
|
||||
private sealed class Waiter { public int Pid; public long EnqueuedTick; }
|
||||
private sealed class Waiter { public int Pid; public int RequestId; public long EnqueuedTick; }
|
||||
private sealed class Bucket { public int PlayerCount; public List<Waiter> Waiters = new List<Waiter>(); }
|
||||
|
||||
private readonly long _timeoutTicks;
|
||||
@@ -17,11 +17,11 @@ namespace XWorld.Server.Gateway
|
||||
public Matchmaker(long timeoutTicks) { _timeoutTicks = timeoutTicks; }
|
||||
private static string Key(string g, int v) => $"{g}@{v}";
|
||||
|
||||
public void Enqueue(int pid, string gameId, int version, int playerCount, long tick)
|
||||
public void Enqueue(int pid, string gameId, int version, int playerCount, long tick, int requestId = 0)
|
||||
{
|
||||
string k = Key(gameId, version);
|
||||
if (!_buckets.TryGetValue(k, out var b)) { b = new Bucket { PlayerCount = playerCount }; _buckets[k] = b; }
|
||||
b.Waiters.Add(new Waiter { Pid = pid, EnqueuedTick = tick });
|
||||
b.Waiters.Add(new Waiter { Pid = pid, RequestId = requestId, EnqueuedTick = tick });
|
||||
}
|
||||
|
||||
// 从等待队列移除某玩家(断线时调用);返回是否移除
|
||||
@@ -51,7 +51,7 @@ namespace XWorld.Server.Gateway
|
||||
var m = new Match { GameId = gv[0], Version = int.Parse(gv[1]) };
|
||||
int take = b.Waiters.Count >= b.PlayerCount ? b.PlayerCount : b.Waiters.Count;
|
||||
for (int i = 0; i < take; i++)
|
||||
m.Seats.Add(new Seat { PlayerId = b.Waiters[i].Pid, IsAi = false });
|
||||
m.Seats.Add(new Seat { PlayerId = b.Waiters[i].Pid, RequestId = b.Waiters[i].RequestId, IsAi = false });
|
||||
b.Waiters.RemoveRange(0, take);
|
||||
while (m.Seats.Count < b.PlayerCount)
|
||||
m.Seats.Add(new Seat { PlayerId = -(++_aiSeq), IsAi = true }); // AI 用负 pid
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using XWorld.Framework;
|
||||
using XWorld.Framework.Protocol;
|
||||
@@ -11,14 +12,16 @@ namespace XWorld.Server.Gateway
|
||||
private readonly string _roomId; // 保留作诊断/日志标识
|
||||
private readonly IReadOnlyList<int> _players;
|
||||
private readonly SessionManager _sessions;
|
||||
private readonly Action<NetMessage> _onBroadcast;
|
||||
|
||||
public RoomOutputSink(string roomId, IReadOnlyList<int> players, SessionManager sessions)
|
||||
public RoomOutputSink(string roomId, IReadOnlyList<int> players, SessionManager sessions, Action<NetMessage> onBroadcast = null)
|
||||
{
|
||||
_roomId = roomId; _players = players; _sessions = sessions;
|
||||
_roomId = roomId; _players = players; _sessions = sessions; _onBroadcast = onBroadcast;
|
||||
}
|
||||
|
||||
public void Broadcast(NetMessage message)
|
||||
{
|
||||
_onBroadcast?.Invoke(message);
|
||||
byte[] frame = FrameCodec.Encode(new Frame(Channel.Game, message.Opcode, message.Payload));
|
||||
foreach (var pid in _players)
|
||||
_sessions.GetConnection(pid)?.Send(frame);
|
||||
|
||||
+165
-23
@@ -21,16 +21,31 @@ namespace XWorld.Server.Gateway
|
||||
private readonly Matchmaker _matchmaker;
|
||||
private readonly ChannelsNS.Channel<ServerCommand> _cmds =
|
||||
ChannelsNS.Channel.CreateUnbounded<ServerCommand>();
|
||||
public const long DefaultReconnectWindowTicks = 1800;
|
||||
private const int MaxWorldChatTextLength = 200;
|
||||
private readonly Dictionary<string, List<int>> _roomPlayers = new Dictionary<string, List<int>>();
|
||||
private readonly Dictionary<string, ActiveRoomInfo> _activeRooms = new Dictionary<string, ActiveRoomInfo>();
|
||||
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 Dictionary<int, int> _queuedRequestIds = new Dictionary<int, int>();
|
||||
private readonly Dictionary<int, int> _activeRoomRequestIds = new Dictionary<int, int>();
|
||||
private readonly Random _random = new Random(1);
|
||||
private long _tick;
|
||||
private int _roomSeq;
|
||||
|
||||
public ServerLoop(string gamesRoot, ILogger logger, long reconnectWindowTicks = 100,
|
||||
private sealed class ActiveRoomInfo
|
||||
{
|
||||
public string RoomId;
|
||||
public string GameId;
|
||||
public int Version;
|
||||
public List<PlayerInfo> Players;
|
||||
public Dictionary<int, int> RequestIds = new Dictionary<int, int>();
|
||||
public bool HasLastGameMessage;
|
||||
public NetMessage LastGameMessage;
|
||||
}
|
||||
|
||||
public ServerLoop(string gamesRoot, ILogger logger, long reconnectWindowTicks = DefaultReconnectWindowTicks,
|
||||
long matchTimeoutTicks = 150, string publicKeyPem = null)
|
||||
{
|
||||
_logger = logger;
|
||||
@@ -104,11 +119,12 @@ namespace XWorld.Server.Gateway
|
||||
case ConnectCommand cc:
|
||||
_sessions.OnConnect(cc.PlayerId, cc.Connection);
|
||||
_logger.Info($"session connect pid={cc.PlayerId}");
|
||||
SendRoomResumeIfNeeded(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);
|
||||
CancelQueuedMatch(dc.PlayerId);
|
||||
if (_lobbyPlayers.Remove(dc.PlayerId)) BroadcastLobbyState();
|
||||
break;
|
||||
case FrameCommand fc: Route(fc.PlayerId, fc.Frame); break;
|
||||
@@ -140,13 +156,16 @@ namespace XWorld.Server.Gateway
|
||||
break;
|
||||
case FrameworkOpcode.MatchRequest:
|
||||
var req = MatchRequestMsg.Decode(frame.Payload);
|
||||
EnqueueMatch(pid, req.GameId, req.Version);
|
||||
EnqueueMatch(pid, req.GameId, req.Version, req.RequestId);
|
||||
break;
|
||||
case FrameworkOpcode.GameListRequest:
|
||||
SendFramework(pid, FrameworkOpcode.GameListResponse, BuildGameList().Encode());
|
||||
break;
|
||||
case FrameworkOpcode.RandomMatchRequest:
|
||||
EnqueueRandomMatch(pid);
|
||||
EnqueueRandomMatch(pid, MatchControlMsg.Decode(frame.Payload).RequestId);
|
||||
break;
|
||||
case FrameworkOpcode.MatchCancel:
|
||||
CancelMatchState(pid, MatchControlMsg.Decode(frame.Payload).RequestId);
|
||||
break;
|
||||
case FrameworkOpcode.LobbyJoin:
|
||||
HandleLobbyJoin(pid, frame.Payload);
|
||||
@@ -155,6 +174,7 @@ namespace XWorld.Server.Gateway
|
||||
HandleLobbyMove(pid, frame.Payload);
|
||||
break;
|
||||
case FrameworkOpcode.LobbyLeave:
|
||||
CancelQueuedMatch(pid);
|
||||
if (_lobbyPlayers.Remove(pid)) BroadcastLobbyState();
|
||||
break;
|
||||
case FrameworkOpcode.WorldChatSend:
|
||||
@@ -272,21 +292,23 @@ namespace XWorld.Server.Gateway
|
||||
}
|
||||
}
|
||||
|
||||
private void EnqueueRandomMatch(int pid)
|
||||
private void EnqueueRandomMatch(int pid, int requestId)
|
||||
{
|
||||
var games = BuildGameList().Games;
|
||||
if (games.Count == 0)
|
||||
{
|
||||
SendFramework(pid, FrameworkOpcode.Error,
|
||||
new ErrorMsg { Code = 1, Message = "no minigame available" }.Encode());
|
||||
new ErrorMsg { Code = 1, Message = "no minigame available", RequestId = requestId }.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);
|
||||
if (EnqueueMatch(pid, selected.GameId, selected.Version, requestId))
|
||||
{
|
||||
_logger.Info($"random match pid={pid} assigned {selected.GameId}@{selected.Version}");
|
||||
SendFramework(pid, FrameworkOpcode.MatchAssigned,
|
||||
new MatchAssignedMsg { GameId = selected.GameId, Version = selected.Version, RequestId = requestId }.Encode());
|
||||
}
|
||||
}
|
||||
|
||||
private GameListResponseMsg BuildGameList()
|
||||
@@ -332,17 +354,17 @@ namespace XWorld.Server.Gateway
|
||||
return msg;
|
||||
}
|
||||
|
||||
private void EnqueueMatch(int pid, string gameId, int version)
|
||||
private bool EnqueueMatch(int pid, string gameId, int version, int requestId = 0)
|
||||
{
|
||||
var s = _sessions.Get(pid);
|
||||
if (s == null) return; // 未建立会话,忽略
|
||||
if (s == null) return false; // 未建立会话,忽略
|
||||
|
||||
if (s.RoomId != null || _queuedPids.Contains(pid))
|
||||
{
|
||||
// 已在房间内或已在队列中:拒绝
|
||||
SendFramework(pid, FrameworkOpcode.Error,
|
||||
new ErrorMsg { Code = 2, Message = "already in room or queue" }.Encode());
|
||||
return;
|
||||
new ErrorMsg { Code = 2, Message = "already in room or queue", RequestId = requestId }.Encode());
|
||||
return false;
|
||||
}
|
||||
|
||||
// 读取游戏的 playerCount(缓存)
|
||||
@@ -360,14 +382,76 @@ namespace XWorld.Server.Gateway
|
||||
{
|
||||
_logger.Error($"读取 game.json 失败 {gameId}@{version}: {ex.Message}");
|
||||
SendFramework(pid, FrameworkOpcode.Error,
|
||||
new ErrorMsg { Code = 1, Message = "game not found" }.Encode());
|
||||
return;
|
||||
new ErrorMsg { Code = 1, Message = "game not found", RequestId = requestId }.Encode());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
_queuedPids.Add(pid);
|
||||
_matchmaker.Enqueue(pid, gameId, version, playerCount, _tick);
|
||||
_logger.Info($"enqueue match pid={pid} game={gameId}@{version} playerCount={playerCount} tick={_tick}");
|
||||
_queuedRequestIds[pid] = requestId;
|
||||
_matchmaker.Enqueue(pid, gameId, version, playerCount, _tick, requestId);
|
||||
_logger.Info($"enqueue match pid={pid} req={requestId} game={gameId}@{version} playerCount={playerCount} tick={_tick}");
|
||||
return true;
|
||||
}
|
||||
|
||||
private void CancelQueuedMatch(int pid, int requestId = 0)
|
||||
{
|
||||
if (requestId != 0 &&
|
||||
_queuedRequestIds.TryGetValue(pid, out int queuedRequestId) &&
|
||||
queuedRequestId != requestId)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (_queuedPids.Remove(pid))
|
||||
{
|
||||
_queuedRequestIds.Remove(pid);
|
||||
_matchmaker.Remove(pid);
|
||||
_logger.Info($"cancel queued match pid={pid} req={requestId}");
|
||||
}
|
||||
}
|
||||
|
||||
private void CancelMatchState(int pid, int requestId)
|
||||
{
|
||||
CancelQueuedMatch(pid, requestId);
|
||||
CancelActiveRoom(pid, requestId);
|
||||
}
|
||||
|
||||
private void CancelActiveRoom(int pid, int requestId)
|
||||
{
|
||||
var s = _sessions.Get(pid);
|
||||
if (s?.RoomId == null) return;
|
||||
if (requestId != 0 &&
|
||||
_activeRoomRequestIds.TryGetValue(pid, out int activeRequestId) &&
|
||||
activeRequestId != requestId)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
string roomId = s.RoomId;
|
||||
|
||||
if (_roomPlayers.TryGetValue(roomId, out var players))
|
||||
{
|
||||
var roomPids = new List<int>(players);
|
||||
_roomPlayers.Remove(roomId);
|
||||
_activeRooms.Remove(roomId);
|
||||
for (int i = 0; i < roomPids.Count; i++)
|
||||
{
|
||||
int roomPid = roomPids[i];
|
||||
var roomSession = _sessions.Get(roomPid);
|
||||
if (roomSession?.RoomId == roomId) roomSession.RoomId = null;
|
||||
_activeRoomRequestIds.Remove(roomPid);
|
||||
}
|
||||
_host.EndRoom(roomId);
|
||||
}
|
||||
else
|
||||
{
|
||||
_activeRooms.Remove(roomId);
|
||||
s.RoomId = null;
|
||||
_activeRoomRequestIds.Remove(pid);
|
||||
}
|
||||
|
||||
_logger.Info($"cancel active match pid={pid} req={requestId} room={roomId}");
|
||||
}
|
||||
|
||||
private void CreateRoomForSeats(Matchmaker.Match match)
|
||||
@@ -397,10 +481,23 @@ namespace XWorld.Server.Gateway
|
||||
{
|
||||
realPids.Add(seat.PlayerId);
|
||||
_queuedPids.Remove(seat.PlayerId);
|
||||
_queuedRequestIds.Remove(seat.PlayerId);
|
||||
}
|
||||
}
|
||||
|
||||
var sink = new RoomOutputSink(roomId, realPids, _sessions);
|
||||
var allPlayers = new List<PlayerInfo>(playerInfos);
|
||||
var roomInfo = new ActiveRoomInfo
|
||||
{
|
||||
RoomId = roomId,
|
||||
GameId = match.GameId,
|
||||
Version = match.Version,
|
||||
Players = allPlayers
|
||||
};
|
||||
var sink = new RoomOutputSink(roomId, realPids, _sessions, message =>
|
||||
{
|
||||
roomInfo.HasLastGameMessage = true;
|
||||
roomInfo.LastGameMessage = new NetMessage(message.Opcode, message.Payload ?? Array.Empty<byte>());
|
||||
});
|
||||
var cfg = new RoomConfig
|
||||
{
|
||||
GameId = match.GameId,
|
||||
@@ -409,6 +506,7 @@ namespace XWorld.Server.Gateway
|
||||
PlayerCount = seats.Count
|
||||
};
|
||||
|
||||
_activeRooms[roomId] = roomInfo;
|
||||
Room room;
|
||||
try
|
||||
{
|
||||
@@ -417,6 +515,7 @@ namespace XWorld.Server.Gateway
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"建房失败 {match.GameId}@{match.Version}: {ex.Message}");
|
||||
_activeRooms.Remove(roomId);
|
||||
// Notify real players of error
|
||||
var errPayload = new ErrorMsg { Code = 1, Message = "create room failed" }.Encode();
|
||||
foreach (int rpid in realPids)
|
||||
@@ -424,20 +523,30 @@ namespace XWorld.Server.Gateway
|
||||
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;
|
||||
int requestId = 0;
|
||||
for (int i = 0; i < seats.Count; i++)
|
||||
{
|
||||
if (!seats[i].IsAi && seats[i].PlayerId == rpid)
|
||||
{
|
||||
requestId = seats[i].RequestId;
|
||||
break;
|
||||
}
|
||||
}
|
||||
_activeRoomRequestIds[rpid] = requestId;
|
||||
roomInfo.RequestIds[rpid] = requestId;
|
||||
|
||||
var found = new MatchFoundMsg
|
||||
{
|
||||
GameId = match.GameId,
|
||||
RoomId = roomId,
|
||||
Version = match.Version,
|
||||
SelfPlayerId = rpid
|
||||
SelfPlayerId = rpid,
|
||||
RequestId = requestId
|
||||
};
|
||||
foreach (var pi in allPlayers) found.Players.Add(pi);
|
||||
SendFramework(rpid, FrameworkOpcode.MatchFound, found.Encode());
|
||||
@@ -452,6 +561,7 @@ namespace XWorld.Server.Gateway
|
||||
{
|
||||
if (!_roomPlayers.TryGetValue(roomId, out var players)) return;
|
||||
_roomPlayers.Remove(roomId);
|
||||
_activeRooms.Remove(roomId);
|
||||
var msg = new RoomEndMsg
|
||||
{
|
||||
WinnerPlayerId = result?.WinnerPlayerId ?? 0,
|
||||
@@ -464,6 +574,7 @@ namespace XWorld.Server.Gateway
|
||||
SendFramework(pid, FrameworkOpcode.RoomEnd, payload);
|
||||
var s = _sessions.Get(pid);
|
||||
if (s != null) s.RoomId = null;
|
||||
_activeRoomRequestIds.Remove(pid);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -472,5 +583,36 @@ namespace XWorld.Server.Gateway
|
||||
_sessions.GetConnection(pid)?.Send(
|
||||
FrameCodec.Encode(new Frame(Channel.Framework, (ushort)op, payload)));
|
||||
}
|
||||
|
||||
private void SendGame(int pid, NetMessage message)
|
||||
{
|
||||
_sessions.GetConnection(pid)?.Send(
|
||||
FrameCodec.Encode(new Frame(Channel.Game, message.Opcode, message.Payload)));
|
||||
}
|
||||
|
||||
private void SendRoomResumeIfNeeded(int pid)
|
||||
{
|
||||
var session = _sessions.Get(pid);
|
||||
if (session?.RoomId == null) return;
|
||||
if (!_activeRooms.TryGetValue(session.RoomId, out var room))
|
||||
{
|
||||
session.RoomId = null;
|
||||
_activeRoomRequestIds.Remove(pid);
|
||||
return;
|
||||
}
|
||||
|
||||
var found = new MatchFoundMsg
|
||||
{
|
||||
GameId = room.GameId,
|
||||
RoomId = room.RoomId,
|
||||
Version = room.Version,
|
||||
SelfPlayerId = pid,
|
||||
RequestId = room.RequestIds.TryGetValue(pid, out int requestId) ? requestId : 0
|
||||
};
|
||||
for (int i = 0; i < room.Players.Count; i++) found.Players.Add(room.Players[i]);
|
||||
SendFramework(pid, FrameworkOpcode.MatchFound, found.Encode());
|
||||
if (room.HasLastGameMessage) SendGame(pid, room.LastGameMessage);
|
||||
_logger.Info($"resume room pid={pid} room={room.RoomId} game={room.GameId}@{room.Version}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,6 +74,23 @@ namespace XWorld.Server.Host.Tests
|
||||
() => host.CreateRoom("dup", "rps", 1, Cfg(), Players, new Capture()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EndRoom_ReleasesRoomImmediately()
|
||||
{
|
||||
var host = NewHost();
|
||||
var cap = new Capture();
|
||||
host.CreateRoom("manual", "rps", 1, Cfg(), Players, cap);
|
||||
|
||||
Assert.True(host.EndRoom("manual"));
|
||||
Assert.Equal(0, host.ActiveRoomCount);
|
||||
|
||||
int broadcasts = cap.Broadcasts.Count;
|
||||
host.DeliverTo("manual", 1, new NetMessage(1, new byte[] { (byte)Choice.Rock }));
|
||||
host.TickAll(0.1f);
|
||||
|
||||
Assert.Equal(broadcasts, cap.Broadcasts.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TickAll_ReturnsEndedRoomIds()
|
||||
{
|
||||
|
||||
@@ -84,6 +84,14 @@ namespace XWorld.Server.Host
|
||||
entry.Room.Deliver(playerId, message);
|
||||
}
|
||||
|
||||
public bool EndRoom(string roomId)
|
||||
{
|
||||
if (!_rooms.TryGetValue(roomId, out var entry)) return false;
|
||||
entry.Room.End();
|
||||
ReleaseRoom(roomId);
|
||||
return true;
|
||||
}
|
||||
|
||||
private void ReleaseRoom(string roomId)
|
||||
{
|
||||
if (!_rooms.TryGetValue(roomId, out var entry)) return;
|
||||
|
||||
Reference in New Issue
Block a user