using System.Collections.Generic;
using Xunit;
using XWorld.Framework;
using XWorld.Framework.Protocol;
using XWorld.Server.Host;
using XWorld.Server.Gateway;
namespace XWorld.Server.Gateway.Tests
{
public class ServerLoopTests
{
private static ServerLoop NewLoop(long reconnectWindow = 100)
=> new ServerLoop(TestGames.Root, new ConsoleLogger("[loop]"), reconnectWindow);
private static byte[] JoinFrame(string gameId, int version)
=> FrameCodec.Encode(new Frame(Channel.Framework, (ushort)FrameworkOpcode.MatchRequest,
new MatchRequestMsg { GameId = gameId, Version = version }.Encode()));
private static Frame LobbyJoinFrame(string name)
=> new Frame(Channel.Framework, (ushort)FrameworkOpcode.LobbyJoin,
new LobbyJoinMsg { Name = name, Figure = 1 }.Encode());
private static Frame LobbyMoveFrame(float x, float z, bool moving)
=> new Frame(Channel.Framework, (ushort)FrameworkOpcode.LobbyMove,
new LobbyMoveMsg
{
X = x,
Y = 0f,
Z = z,
DirX = 1f,
DirZ = 0f,
Moving = moving
}.Encode());
private static Frame WorldChatFrame(string text)
=> new Frame(Channel.Framework, (ushort)FrameworkOpcode.WorldChatSend,
new WorldChatSendMsg { Text = text }.Encode());
private static List Frames(FakeConnection c)
{
var list = new List();
foreach (var bytes in c.Sent) list.Add(FrameCodec.Decode(bytes));
return list;
}
[Fact]
public void Heartbeat_IsEchoed()
{
var loop = NewLoop();
var c = new FakeConnection(1);
loop.Submit(new ConnectCommand { PlayerId = 1, Connection = c });
var hb = new HeartbeatMsg { ClientTimeMs = 123456789L };
loop.Submit(new FrameCommand { PlayerId = 1,
Frame = new Frame(Channel.Framework, (ushort)FrameworkOpcode.Heartbeat, hb.Encode()) });
loop.DrainAndTick(0f);
var echo = Frames(c).Find(f => f.Channel == Channel.Framework && f.Opcode == (ushort)FrameworkOpcode.Heartbeat);
Assert.NotEqual(default, echo);
Assert.Equal(123456789L, HeartbeatMsg.Decode(echo.Payload).ClientTimeMs);
}
[Fact]
public void LobbyMove_IsBroadcastToAllLobbyPlayers()
{
var loop = NewLoop();
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 = LobbyJoinFrame("editor") });
loop.Submit(new FrameCommand { PlayerId = 2, Frame = LobbyJoinFrame("android") });
loop.DrainAndTick(0f);
c1.Sent.Clear();
c2.Sent.Clear();
loop.Submit(new FrameCommand { PlayerId = 1, Frame = LobbyMoveFrame(3f, 4f, true) });
loop.DrainAndTick(0f);
Frame moveToSelf = Frames(c1).Find(f => f.Channel == Channel.Framework && f.Opcode == (ushort)FrameworkOpcode.LobbyMove);
Frame moveToOther = Frames(c2).Find(f => f.Channel == Channel.Framework && f.Opcode == (ushort)FrameworkOpcode.LobbyMove);
Assert.NotEqual(default, moveToSelf);
Assert.NotEqual(default, moveToOther);
LobbyMoveMsg decoded = LobbyMoveMsg.Decode(moveToOther.Payload);
Assert.Equal(1, decoded.PlayerId);
Assert.Equal(3f, decoded.X);
Assert.Equal(4f, decoded.Z);
Assert.True(decoded.Moving);
}
[Fact]
public void WorldChat_IsBroadcastToAllConnectedPlayers()
{
var loop = NewLoop();
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 = LobbyJoinFrame("Alice") });
loop.Submit(new FrameCommand { PlayerId = 2, Frame = LobbyJoinFrame("Bob") });
loop.DrainAndTick(0f);
c1.Sent.Clear();
c2.Sent.Clear();
loop.Submit(new FrameCommand { PlayerId = 1, Frame = WorldChatFrame("hello world") });
loop.DrainAndTick(0f);
Frame selfFrame = Frames(c1).Find(f => f.Channel == Channel.Framework && f.Opcode == (ushort)FrameworkOpcode.WorldChatMessage);
Frame otherFrame = Frames(c2).Find(f => f.Channel == Channel.Framework && f.Opcode == (ushort)FrameworkOpcode.WorldChatMessage);
Assert.NotEqual(default, selfFrame);
Assert.NotEqual(default, otherFrame);
WorldChatMessageMsg message = WorldChatMessageMsg.Decode(otherFrame.Payload);
Assert.Equal(1, message.PlayerId);
Assert.Equal("Alice", message.PlayerName);
Assert.Equal("hello world", message.Text);
Assert.True(message.ServerTimeMs > 0);
}
[Fact]
public void Reconnect_ReroutesRoomOutputToNewConnection()
{
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); // 2 人成团:c1/c2 收到 MatchFound + 快照
Assert.True(c1.Sent.Count >= 2);
// pid=1 断线后重连到新连接 c1b
loop.Submit(new DisconnectCommand { PlayerId = 1, Connection = c1 });
var c1b = new FakeConnection(1);
loop.Submit(new ConnectCommand { PlayerId = 1, Connection = c1b });
loop.DrainAndTick(1f); // 房间仍在跑(60s 未到):下一快照应发往 c1b
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()
{
var loop = NewLoop(reconnectWindow: 2);
var c = new FakeConnection(1);
loop.Submit(new ConnectCommand { PlayerId = 1, Connection = c });
loop.DrainAndTick(1f); // _tick: 0→1
loop.Submit(new DisconnectCommand { PlayerId = 1, Connection = c });
loop.DrainAndTick(1f); // 排空时 _tick=1 → DisconnectedAtTick=1;随后 _tick→2
loop.DrainAndTick(1f); // _tick→3:3-1=2,不 >2,保留
loop.DrainAndTick(1f); // _tick→4:4-1=3 >2 → 过期清除
Assert.False(loop.HasSession(1));
}
[Fact]
public void DuplicateMatchRequest_WhileInRoom_IsRejectedWithError()
{
var loop = NewLoop();
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); // 2 人入房
// 已在房间内再次 join → 回 Error
loop.Submit(new FrameCommand { PlayerId = 1, Frame = FrameCodec.Decode(JoinFrame("rps", 1)) });
loop.DrainAndTick(1f);
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()) });
loop.Submit(new FrameCommand { PlayerId = 1, Frame = new Frame(Channel.Framework, (ushort)FrameworkOpcode.RandomMatchRequest, System.Array.Empty()) });
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()
{
var loop = NewLoop();
var c = new FakeConnection(1);
loop.Submit(new ConnectCommand { PlayerId = 1, Connection = c });
var w = new PacketWriter(); w.WriteVarInt(3);
loop.Submit(new FrameCommand { PlayerId = 1, Frame = new Frame(Channel.Game, 0, w.ToArray()) });
var ex = Record.Exception(() => loop.DrainAndTick(1f));
Assert.Null(ex); // 无房间的游戏帧被安全忽略,不抛
Assert.Empty(c.Sent); // 无任何输出
}
[Fact]
public void MatchRequest_WithNoSession_IsIgnoredSafely()
{
var loop = NewLoop();
// 未发 ConnectCommand,直接来一个 join 帧
loop.Submit(new FrameCommand { PlayerId = 99, Frame = FrameCodec.Decode(JoinFrame("rps", 1)) });
var ex = Record.Exception(() => loop.DrainAndTick(1f));
Assert.Null(ex);
Assert.False(loop.HasSession(99));
}
}
}