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 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 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 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 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)); } } }