Files
AIC-Project/docs/superpowers/plans/2026-06-19-server-ws-gateway-2b1.md
T
2026-07-10 10:24:29 +08:00

53 KiB
Raw Blame History

服务端 WebSocket 网关 + Session + 路由(子项目 2b-1,传输核心)实现计划

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: 给 2a 的 RoomHost 接上真实网络:一个 Kestrel WebSocket 网关,让单个玩家通过真 WS 连接 → 入房(单人 Hello)→ 持续收到状态快照 → 房间结束,并支持断线重连窗口;全部跑在单线程 tick actor(无锁、确定性)上。

Architecture: ASP.NET Core Kestrel 暴露 /ws?pid=<id> 端点。每个连接跑收/发两个异步循环:收到的 WS 消息用 FrameCodec 解码后只把命令入队(不碰 RoomHost);发送侧有独立出站队列。一个 ServerLoopactor)独占 RoomHost 与 SessionManager:它排空命令队列、按 tickRateHz 调 RoomHost.TickAll、把房间广播经 IRoomOutput 汇成 Game 通道帧塞回各玩家连接、并扫描重连窗口过期会话。actor 暴露 Submit(cmd)(线程安全)与 DrainAndTick(dt)(测试直调;真实循环周期性调它),使路由/房间生命周期逻辑可确定性单测,真 socket 管道走真 Kestrel 集成测试。

Tech Stack: C# / net10.0 · ASP.NET Core Kestrel WebSocketsFrameworkReference Microsoft.AspNetCore.App)· System.Threading.Channels(命令队列 + 每连接出站队列)· System.Net.WebSockets.ClientWebSocket(集成测试)· 复用子项目 1 的 FrameCodec/FrameworkMessages/Frame/Channel 与子项目 2a 的 RoomHost/GameModuleRegistry/Room


背景与约定(务必遵守)

  • 上游设计:docs/superpowers/specs/2026-06-18-csharp-hotfix-minigame-architecture-design.md §4.1(接入/路由/Session)。本计划落地 §9 第 2 步的网络前端部分(传输核心)。
  • 依赖:子项目 1Server/Framework.Shared,契约 + 协议)与子项目 2aServer/Server.HostRoomHost/Room/GameModuleRegistry/GameModuleLoader/RoomCtx/IRoomOutput/能力实现)均已完成,dotnet test Server/Server.sln 当前 73/73。
  • 无 git:跳过所有 git 步骤,其余照常。
  • 工作目录 D:/UD/AI/AIC#Projectbash on Windows(正斜杠、/dev/null)。dotnet SDK 10.0.201net10 自带 ASP.NET Core 共享框架)。
  • 本计划不做:匹配(按 (gameId,version) 分桶、凑齐 N 人、15s 超时 AI 兜底)与多人/AI ——这些随 RPS 重写(§9 第 5 步,届时有真正的 2 人游戏)一起做。本计划用单人 Hello 验证整条网络管道。

并发模型(单线程 tick actor,无锁)

  • 网络线程(每连接的收循环):只解码帧 + ServerLoop.Submit(cmd)(写线程安全 Channel)。绝不直接调用 RoomHost/SessionManager。
  • actor 单线程DrainAndTick(dt) 内排空命令、处理路由、RoomHost.TickAll、发 RoomEnd、扫过期会话。RoomHost/SessionManager 只在此处被改 → 天然无锁、房间内确定性。
  • 出站actor 经 IClientConnection.Send(frame) 把帧写进该连接的出站 Channel(线程安全);连接的发循环排空该队列并 WebSocket.SendAsync(每 socket 仅一个发循环 → 发送天然串行化)。

命名与依赖

  • 网关项目 Server/Gateway/,程序集 XWorld.Server.Gateway,命名空间 XWorld.Server.Gatewaynet10.0FrameworkReference Microsoft.AspNetCore.App + 引用 Server.Host
  • 命名冲突处理:协议帧通道枚举是 XWorld.Framework.Protocol.ChannelSystem.Threading.Channels.Channel 用别名 using ChannelsNS = System.Threading.Channels; 引入,写作 ChannelsNS.Channel<T>,而 Channel.Framework/Channel.Game 指协议枚举(using XWorld.Framework.Protocol;)。

文件结构

网关库 Server/Gateway/

文件 职责
Gateway.csproj net10.0FrameworkReference ASP.NET Core,引用 Server.Host
IClientConnection.cs 出站抽象:int PlayerIdvoid Send(byte[] frame)(非阻塞入队)
ServerCommand.cs actor 命令:Connect / Disconnect / Frame
Session.cs 单会话状态:pid、当前连接、是否在线、所在房间、断线 tick
SessionManager.cs pid→Session;连接/重连/断线/查连接/绑房/过期扫描
RoomOutputSink.cs IRoomOutput → 经 SessionManager 解析连接,包成 Game 通道帧入队
ServerLoop.cs actor:命令队列 + RoomHost + SessionSubmit/DrainAndTick/路由/房间结束/心跳/RunAsync
Connection.cs WebSocket 包装:收循环(解码帧→Submit FrameCommand+ 发循环(排空出站→SendAsync)
GameServerHost.cs 建 Kestrel WebApplication + /ws 端点 + 启停 + 解析实际端口

测试 Server/Gateway.Tests/

文件 职责
Gateway.Tests.csproj net10.0 + xUnit,引用 Gateway,复用 Server.Host.Tests 的 TestGames
Support/FakeConnection.cs IClientConnection 假实现,记录发出的帧
Support/TestGames.cs 定位输出目录的 TestGames 根
Support/WsTestClient.cs 集成测试用 ClientWebSocket 封装(连/发帧/带超时收帧/解码)
SessionManagerTests.cs / RoomOutputSinkTests.cs / ServerLoopTests.cs / GatewayEndToEndTests.cs 见各任务

被修改:

文件 改动
Server/Server.Host/RoomHost.cs TickAll(dt) 改为返回本 tick 结束的 roomId 列表(向后兼容:旧调用忽略返回值即可)

Task 1: 修改 RoomHost.TickAll 返回本 tick 结束的房间(Server.Host

Files:

  • Modify: Server/Server.Host/RoomHost.cs
  • Test: Server/Server.Host.Tests/RoomHostTests.cs(追加一个断言用例)

actor 需要知道哪些房间在本 tick 结束,以便给玩家发 RoomEnd 并解绑会话。

  • Step 1: 先写失败测试(追加到 RoomHostTests.cs 类内)
        [Fact]
        public void TickAll_ReturnsEndedRoomIds()
        {
            var host = NewHost();
            var cfg = new RoomConfig { GameId = "hello", Version = 1, Seed = 1, PlayerCount = 1 };
            host.CreateRoom("E", "hello", 1, cfg, Players, new Capture());

            var tick1 = host.TickAll(1f);
            var tick2 = host.TickAll(1f);
            var tick3 = host.TickAll(1f); // Hello 第 3 tick 结束
            Assert.Empty(tick1);          // 前两 tick 不应有结束房间
            Assert.Empty(tick2);
            Assert.Contains("E", tick3);  // 第 3 tick 才结束
        }
  • Step 2: 运行,确认红

Run: bash Server/build-test-games.sh && dotnet test Server/Server.sln --filter RoomHostTests Expected: 编译失败(TickAll 当前返回 voidended = host.TickAll(...) 无法赋值)。

  • Step 3: 修改 RoomHost.TickAll 返回类型

RoomHost.cs 中的 TickAll 方法整体替换为:

        public IReadOnlyList<string> TickAll(float dt)
        {
            List<string> ended = null;
            foreach (var kv in _rooms)
            {
                var room = kv.Value.Room;
                if (!room.IsActive) { (ended ??= new List<string>()).Add(kv.Key); continue; }
                room.Tick(dt);
                if (!room.IsActive) (ended ??= new List<string>()).Add(kv.Key);
            }
            if (ended != null)
                foreach (var id in ended) ReleaseRoom(id);
            return ended ?? (IReadOnlyList<string>)System.Array.Empty<string>();
        }

其余方法不变。(旧测试 host.TickAll(1f) 忽略返回值,仍编译通过。)

  • Step 4: 运行,确认绿

Run: bash Server/build-test-games.sh && dotnet test Server/Server.sln --filter RoomHostTests Expected: PASS(含新用例)。


Task 2: 搭建网关项目与测试工程

Files:

  • Create: Server/Gateway/Gateway.csproj

  • Create: Server/Gateway.Tests/Gateway.Tests.csproj

  • Create: Server/Gateway.Tests/Support/TestGames.cs

  • Modify: Server/Server.sln

  • Step 1: 生成工程并并入解决方案

dotnet new classlib -n Gateway -o Server/Gateway -f net10.0
dotnet new xunit    -n Gateway.Tests -o Server/Gateway.Tests
rm -f Server/Gateway/Class1.cs Server/Gateway.Tests/UnitTest1.cs
dotnet sln Server/Server.sln add Server/Gateway/Gateway.csproj
dotnet sln Server/Server.sln add Server/Gateway.Tests/Gateway.Tests.csproj
mkdir -p Server/Gateway.Tests/Support
  • Step 2: 写 Server/Gateway/Gateway.csproj
<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <Nullable>disable</Nullable>
    <ImplicitUsings>disable</ImplicitUsings>
    <AssemblyName>XWorld.Server.Gateway</AssemblyName>
    <RootNamespace>XWorld.Server.Gateway</RootNamespace>
  </PropertyGroup>

  <ItemGroup>
    <FrameworkReference Include="Microsoft.AspNetCore.App" />
    <ProjectReference Include="..\Server.Host\Server.Host.csproj" />
  </ItemGroup>

</Project>
  • Step 3: 配置 Server/Gateway.Tests/Gateway.Tests.csproj

</Project> 前加入(模板已含 xUnit 包;补 Nullable disable、引用、TestGames 内容复用)。先把 <PropertyGroup> 里加一行 <Nullable>disable</Nullable>(若模板默认 enable),再加:

  <ItemGroup>
    <ProjectReference Include="..\Gateway\Gateway.csproj" />
  </ItemGroup>

  <ItemGroup>
    <!-- 复用 2a 已构建的 Hello 夹具(由 build-test-games.sh 生成到 Server.Host.Tests/TestGames -->
    <Content Include="..\Server.Host.Tests\TestGames\**\*">
      <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
      <Link>TestGames\%(RecursiveDir)%(Filename)%(Extension)</Link>
    </Content>
  </ItemGroup>
  • Step 4: 写 Server/Gateway.Tests/Support/TestGames.cs
using System;
using System.IO;

namespace XWorld.Server.Gateway.Tests
{
    internal static class TestGames
    {
        // 前置条件:先运行 build-test-games.sh 生成 Server.Host.Tests/TestGames(本工程复用之)
        public static string Root => Path.Combine(AppContext.BaseDirectory, "TestGames");
    }
}
  • Step 5: 构建确认(夹具须先生成)

Run: bash Server/build-test-games.sh && dotnet build Server/Server.sln -v minimal Expected: Build succeeded0 error。然后 ls "Server/Gateway.Tests/bin/Debug/net10.0/TestGames/hello/1" 列出 Hello.Core.dllHello.Server.dllgame.json


Task 3: 出站抽象 + 会话管理(单元 TDD)

Files:

  • Create: Server/Gateway/IClientConnection.cs

  • Create: Server/Gateway/Session.cs

  • Create: Server/Gateway/SessionManager.cs

  • Create: Server/Gateway.Tests/Support/FakeConnection.cs

  • Test: Server/Gateway.Tests/SessionManagerTests.cs

  • Step 1: 写 Support/FakeConnection.cs

using System.Collections.Generic;

namespace XWorld.Server.Gateway.Tests
{
    internal sealed class FakeConnection : IClientConnection
    {
        public int PlayerId { get; }
        public List<byte[]> Sent { get; } = new List<byte[]>();
        public FakeConnection(int playerId) { PlayerId = playerId; }
        public void Send(byte[] frame) => Sent.Add(frame);
    }
}
  • Step 2: 写失败测试 SessionManagerTests.cs
using Xunit;
using XWorld.Server.Gateway;

namespace XWorld.Server.Gateway.Tests
{
    public class SessionManagerTests
    {
        [Fact]
        public void Connect_NewPid_CreatesConnectedSession()
        {
            var sm = new SessionManager();
            var c = new FakeConnection(1);
            sm.OnConnect(1, c);
            Assert.True(sm.IsConnected(1));
            Assert.Same(c, sm.GetConnection(1));
        }

        [Fact]
        public void Reconnect_SamePid_RebindsToNewConnection()
        {
            var sm = new SessionManager();
            var c1 = new FakeConnection(1);
            var c2 = new FakeConnection(1);
            sm.OnConnect(1, c1);
            sm.OnDisconnect(1, c1, currentTick: 5);
            Assert.False(sm.IsConnected(1));
            Assert.Null(sm.GetConnection(1));

            sm.OnConnect(1, c2);              // 重连
            Assert.True(sm.IsConnected(1));
            Assert.Same(c2, sm.GetConnection(1)); // 路由到新连接
        }

        [Fact]
        public void StaleDisconnect_AfterReconnect_DoesNotClobber()
        {
            var sm = new SessionManager();
            var c1 = new FakeConnection(1);
            var c2 = new FakeConnection(1);
            sm.OnConnect(1, c1);
            sm.OnConnect(1, c2);                 // 已重连到 c2
            sm.OnDisconnect(1, c1, currentTick: 9); // 旧连接 c1 的迟到断线不应清掉 c2
            Assert.True(sm.IsConnected(1));
            Assert.Same(c2, sm.GetConnection(1));
        }

        [Fact]
        public void RoomBinding_RoundTrips()
        {
            var sm = new SessionManager();
            sm.OnConnect(1, new FakeConnection(1));
            sm.Get(1).RoomId = "r1";
            Assert.Equal("r1", sm.Get(1).RoomId);
        }

        [Fact]
        public void SweepExpired_RemovesSessionsPastWindow()
        {
            var sm = new SessionManager();
            var conn = new FakeConnection(1);
            sm.OnConnect(1, conn);
            sm.OnDisconnect(1, conn, currentTick: 0);

            var none = sm.SweepExpired(currentTick: 3, windowTicks: 5);
            Assert.Empty(none);
            Assert.NotNull(sm.Get(1));

            var removed = sm.SweepExpired(currentTick: 6, windowTicks: 5); // 6-0 > 5
            Assert.Single(removed);
            Assert.Null(sm.Get(1));
        }
    }
}
  • Step 3: 运行,确认红

Run: dotnet test Server/Server.sln --filter SessionManagerTests Expected: 编译失败(相关类型不存在)。

  • Step 4: 写 IClientConnection.cs
namespace XWorld.Server.Gateway
{
    // actor 把出站帧交给它(非阻塞入队);生产实现写 WebSocket 出站队列,测试用假实现记录
    public interface IClientConnection
    {
        int PlayerId { get; }
        // frame 在调用后不会被修改:实现可保留该引用(广播会把同一数组发给多个连接)。
        void Send(byte[] frame);
    }
}
  • Step 5: 写 Session.cs
namespace XWorld.Server.Gateway
{
    public sealed class Session
    {
        public int PlayerId;
        public IClientConnection Connection; // 当前连接;断线中仍保留会话但 Connected=false
        public bool Connected;
        public string RoomId;                // 所在房间,可空
        public long DisconnectedAtTick;      // 断线时的逻辑 tick,用于窗口过期
    }
}
  • Step 6: 写 SessionManager.cs
using System;
using System.Collections.Generic;

namespace XWorld.Server.Gateway
{
    // pid -> Session。只在 actor 线程被改,故无锁。
    public sealed class SessionManager
    {
        private readonly Dictionary<int, Session> _byPid = new Dictionary<int, Session>();

        public Session OnConnect(int pid, IClientConnection conn)
        {
            if (_byPid.TryGetValue(pid, out var s))
            {
                s.Connection = conn;   // 重连:重绑新连接,保留 RoomId
                s.Connected = true;
                return s;
            }
            s = new Session { PlayerId = pid, Connection = conn, Connected = true };
            _byPid[pid] = s;
            return s;
        }

        public void OnDisconnect(int pid, IClientConnection conn, long currentTick)
        {
            // 仅当断的是“当前连接”才标离线,避免旧连接的迟到断线清掉已重连的新连接
            if (_byPid.TryGetValue(pid, out var s) && ReferenceEquals(s.Connection, conn))
            {
                s.Connected = false;
                s.DisconnectedAtTick = currentTick;
            }
        }

        public IClientConnection GetConnection(int pid)
            => _byPid.TryGetValue(pid, out var s) && s.Connected ? s.Connection : null;

        public bool IsConnected(int pid) => _byPid.TryGetValue(pid, out var s) && s.Connected;

        public Session Get(int pid) => _byPid.TryGetValue(pid, out var s) ? s : null;

        /// <summary>移除断线且“严格越过”重连窗口的会话(currentTick - DisconnectedAtTick &gt; windowTicks)。返回被移除的会话;无则空数组。</summary>
        public IReadOnlyList<Session> SweepExpired(long currentTick, long windowTicks)
        {
            List<Session> removed = null;
            foreach (var kv in _byPid)
            {
                var s = kv.Value;
                if (!s.Connected && currentTick - s.DisconnectedAtTick > windowTicks)
                    (removed ??= new List<Session>()).Add(s);
            }
            if (removed != null)
                foreach (var s in removed) _byPid.Remove(s.PlayerId);
            return removed ?? (IReadOnlyList<Session>)Array.Empty<Session>();
        }
    }
}
  • Step 7: 运行,确认绿

Run: dotnet test Server/Server.sln --filter SessionManagerTests Expected: PASS。


Task 4: 房间出站汇 RoomOutputSink(单元 TDD

Files:

  • Create: Server/Gateway/RoomOutputSink.cs

  • Test: Server/Gateway.Tests/RoomOutputSinkTests.cs

  • Step 1: 写失败测试 RoomOutputSinkTests.cs

using System.Collections.Generic;
using Xunit;
using XWorld.Framework;
using XWorld.Framework.Protocol;
using XWorld.Server.Gateway;

namespace XWorld.Server.Gateway.Tests
{
    public class RoomOutputSinkTests
    {
        [Fact]
        public void Broadcast_WrapsInGameChannelFrame_ToConnectedPlayers()
        {
            var sm = new SessionManager();
            var c1 = new FakeConnection(1);
            var c2 = new FakeConnection(2);
            sm.OnConnect(1, c1);
            sm.OnConnect(2, c2);

            var sink = new RoomOutputSink("r1", new List<int> { 1, 2 }, sm);
            sink.Broadcast(new NetMessage(7, new byte[] { 9, 9 }));

            Assert.Single(c1.Sent);
            Assert.Single(c2.Sent);
            var f = FrameCodec.Decode(c1.Sent[0]);
            Assert.Equal(Channel.Game, f.Channel);
            Assert.Equal((ushort)7, f.Opcode);
            Assert.Equal(new byte[] { 9, 9 }, f.Payload);
        }

        [Fact]
        public void Broadcast_SkipsDisconnectedPlayer()
        {
            var sm = new SessionManager();
            var c1 = new FakeConnection(1);
            sm.OnConnect(1, c1);
            sm.OnConnect(2, new FakeConnection(2));
            sm.OnDisconnect(2, sm.GetConnection(2), 0); // 玩家2 离线

            var sink = new RoomOutputSink("r1", new List<int> { 1, 2 }, sm);
            sink.Broadcast(new NetMessage(1, new byte[0]));

            Assert.Single(c1.Sent);     // 仅在线玩家收到,不抛异常
        }

        [Fact]
        public void SendTo_OnlyTargetPlayer()
        {
            var sm = new SessionManager();
            var c1 = new FakeConnection(1);
            var c2 = new FakeConnection(2);
            sm.OnConnect(1, c1);
            sm.OnConnect(2, c2);

            var sink = new RoomOutputSink("r1", new List<int> { 1, 2 }, sm);
            sink.SendTo(2, new NetMessage(5, new byte[] { 1 }));

            Assert.Empty(c1.Sent);
            Assert.Single(c2.Sent);
        }

        [Fact]
        public void Broadcast_AfterReconnect_GoesToNewConnection()
        {
            var sm = new SessionManager();
            var c1 = new FakeConnection(1);
            sm.OnConnect(1, c1);
            var sink = new RoomOutputSink("r1", new List<int> { 1 }, sm);

            sink.Broadcast(new NetMessage(1, new byte[] { 1 }));
            Assert.Single(c1.Sent);

            sm.OnDisconnect(1, c1, 0);           // 断线
            var c2 = new FakeConnection(1);
            sm.OnConnect(1, c2);                  // 重连到新连接

            sink.Broadcast(new NetMessage(1, new byte[] { 2 }));
            Assert.Single(c1.Sent);              // 旧连接不再收到
            Assert.Single(c2.Sent);              // 输出跟随重连到新连接
        }
    }
}
  • Step 2: 运行,确认红

Run: dotnet test Server/Server.sln --filter RoomOutputSinkTests Expected: 编译失败(RoomOutputSink 不存在)。

  • Step 3: 写 RoomOutputSink.cs
using System.Collections.Generic;
using XWorld.Framework;
using XWorld.Framework.Protocol;

namespace XWorld.Server.Gateway
{
    // 房间广播 → 解析各玩家当前连接(重连后自动指向新连接)→ 包成 Game 通道帧入对应出站队列
    public sealed class RoomOutputSink : IRoomOutput
    {
        private readonly string _roomId; // 保留作诊断/日志标识
        private readonly IReadOnlyList<int> _players;
        private readonly SessionManager _sessions;

        public RoomOutputSink(string roomId, IReadOnlyList<int> players, SessionManager sessions)
        {
            _roomId = roomId; _players = players; _sessions = sessions;
        }

        public void Broadcast(NetMessage message)
        {
            byte[] frame = FrameCodec.Encode(new Frame(Channel.Game, message.Opcode, message.Payload));
            foreach (var pid in _players)
                _sessions.GetConnection(pid)?.Send(frame);
        }

        public void SendTo(int playerId, NetMessage message)
        {
            var conn = _sessions.GetConnection(playerId);
            if (conn != null)
                conn.Send(FrameCodec.Encode(new Frame(Channel.Game, message.Opcode, message.Payload)));
        }
    }
}
  • Step 4: 运行,确认绿

Run: dotnet test Server/Server.sln --filter RoomOutputSinkTests Expected: PASS。


Task 5: actor 核心 ServerLoop(单元 TDD,含路由/房间生命周期/心跳/重连)

Files:

  • Create: Server/Gateway/ServerCommand.cs
  • Create: Server/Gateway/ServerLoop.cs
  • Test: Server/Gateway.Tests/ServerLoopTests.cs

DrainAndTick(dt) 直接驱动,配合 FakeConnection + 真 Hello 房间(从 TestGames 加载),无需任何 socket,确定性测试 actor 逻辑。

  • Step 1: 写失败测试 ServerLoopTests.cs
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()));

        // 把假连接收到的帧按 (channel, opcode) 分类
        private static List<Frame> Frames(FakeConnection c)
        {
            var list = new List<Frame>();
            foreach (var bytes in c.Sent) list.Add(FrameCodec.Decode(bytes));
            return list;
        }

        private static int LastGameCounter(FakeConnection c)
        {
            int counter = -1;
            foreach (var f in Frames(c))
                if (f.Channel == Channel.Game)
                {
                    var r = new PacketReader(f.Payload);
                    r.ReadVarInt();             // tick
                    counter = r.ReadVarInt();   // counter
                }
            return counter;
        }

        [Fact]
        public void Join_RepliesMatchFound_Snapshots_ThenRoomEnd()
        {
            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("hello", 1)) });

            for (int i = 0; i < 3; i++) loop.DrainAndTick(1f); // Hello 第 3 tick 结束

            var frames = Frames(c);
            // 1 个 MatchFoundFramework+ 3 个快照(Game+ 1 个 RoomEndFramework
            Assert.Contains(frames, f => f.Channel == Channel.Framework && f.Opcode == (ushort)FrameworkOpcode.MatchFound);
            Assert.Equal(3, frames.FindAll(f => f.Channel == Channel.Game).Count);
            Assert.Contains(frames, f => f.Channel == Channel.Framework && f.Opcode == (ushort)FrameworkOpcode.RoomEnd);
            Assert.Equal(3, LastGameCounter(c)); // v1 起始 03 次 +1
        }

        [Fact]
        public void GameInput_RoutedToRoom_AffectsCounter()
        {
            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("hello", 1)) });

            // 入房后先送一条游戏输入(增量 5),再推进
            var w = new PacketWriter(); w.WriteVarInt(5);
            var inputFrame = new Frame(Channel.Game, 0, w.ToArray());
            loop.Submit(new FrameCommand { PlayerId = 1, Frame = inputFrame });

            loop.DrainAndTick(1f); // 处理 connect+join+input 后 tick 一次:counter = 0 + 5 = 5
            Assert.Equal(5, LastGameCounter(c));
        }

        [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 Reconnect_ReroutesRoomOutputToNewConnection()
        {
            var loop = NewLoop(reconnectWindow: 100);
            var c1 = new FakeConnection(1);
            loop.Submit(new ConnectCommand { PlayerId = 1, Connection = c1 });
            loop.Submit(new FrameCommand { PlayerId = 1, Frame = FrameCodec.Decode(JoinFrame("hello", 1)) });
            loop.DrainAndTick(1f); // tick1c1 收到 MatchFound + 快照1
            Assert.True(c1.Sent.Count >= 2);

            // 断线后重连到新连接
            loop.Submit(new DisconnectCommand { PlayerId = 1, Connection = c1 });
            var c2 = new FakeConnection(1);
            loop.Submit(new ConnectCommand { PlayerId = 1, Connection = c2 });
            loop.DrainAndTick(1f); // tick2:快照应发往 c2(房间仍在跑)

            Assert.True(c2.Sent.Count >= 1);
            Assert.Equal(2, LastGameCounter(c2)); // 重连后看到的最新计数 = 2
        }

        [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→33-1=2,不 >2,保留
            loop.DrainAndTick(1f); // _tick→44-1=3 >2 → 过期清除
            Assert.False(loop.HasSession(1));
        }

        [Fact]
        public void DuplicateMatchRequest_WhileInRoom_IsRejectedWithError()
        {
            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("hello", 1)) });
            loop.DrainAndTick(1f); // 入房 r1Hello 第 3 tick 才结束)

            loop.Submit(new FrameCommand { PlayerId = 1, Frame = FrameCodec.Decode(JoinFrame("hello", 1)) });
            loop.DrainAndTick(1f); // 第二次 join:已在房间 → 回 Error

            Assert.Contains(Frames(c), 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("hello", 1)) });
            var ex = Record.Exception(() => loop.DrainAndTick(1f));
            Assert.Null(ex);
            Assert.False(loop.HasSession(99));
        }
    }
}
  • Step 2: 运行,确认红

Run: bash Server/build-test-games.sh && dotnet test Server/Server.sln --filter ServerLoopTests Expected: 编译失败(ServerLoop/命令类型不存在)。

  • Step 3: 写 ServerCommand.cs
using XWorld.Framework.Protocol;

namespace XWorld.Server.Gateway
{
    public abstract class ServerCommand { public int PlayerId; }

    public sealed class ConnectCommand : ServerCommand { public IClientConnection Connection; }

    public sealed class DisconnectCommand : ServerCommand { public IClientConnection Connection; }

    public sealed class FrameCommand : ServerCommand { public Frame Frame; }
}
  • Step 4: 写 ServerLoop.cs
using System;
using System.Collections.Generic;
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 ChannelsNS.Channel<ServerCommand> _cmds =
            ChannelsNS.Channel.CreateUnbounded<ServerCommand>();
        private readonly Dictionary<string, List<int>> _roomPlayers = new Dictionary<string, List<int>>();
        private long _tick;
        private int _roomSeq;

        public ServerLoop(string gamesRoot, ILogger logger, long reconnectWindowTicks = 100)
        {
            _logger = logger;
            _reconnectWindowTicks = reconnectWindowTicks;
            _host = new RoomHost(new GameModuleRegistry(new GameModuleLoader(), gamesRoot), logger);
        }

        // 线程安全:网络线程调用,仅入队
        public void Submit(ServerCommand cmd) => _cmds.Writer.TryWrite(cmd);

        // 仅 actor 线程调用(_sessions 无锁);测试在单线程下使用
        public bool HasSession(int pid) => _sessions.Get(pid) != null;

        // 测试直调;真实循环周期性调它。先排空命令,再推进一 tick,处理结束房间与过期会话。
        public void DrainAndTick(float dt)
        {
            while (_cmds.Reader.TryRead(out var cmd)) Process(cmd);
            _tick++;
            var ended = _host.TickAll(dt);
            for (int i = 0; i < ended.Count; i++) OnRoomEnded(ended[i]);
            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)
        {
            float dt = tickIntervalMs / 1000f;
            while (!ct.IsCancellationRequested)
            {
                DrainAndTick(dt);
                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); break;
                case DisconnectCommand dc: _sessions.OnDisconnect(dc.PlayerId, dc.Connection, _tick); 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)
        {
            switch ((FrameworkOpcode)frame.Opcode)
            {
                case FrameworkOpcode.Heartbeat:
                    SendFramework(pid, FrameworkOpcode.Heartbeat, frame.Payload); // 原样回显
                    break;
                case FrameworkOpcode.MatchRequest:
                    var req = MatchRequestMsg.Decode(frame.Payload);
                    CreateRoomFor(pid, req.GameId, req.Version);
                    break;
            }
        }

        private void CreateRoomFor(int pid, string gameId, int version)
        {
            var s = _sessions.Get(pid);
            if (s == null) return; // 未建立会话,忽略
            if (s.RoomId != null)
            {
                // 已在房间内(2b-1 暂无换房/离房流程):拒绝,避免覆盖 RoomId 造成状态损坏
                SendFramework(pid, FrameworkOpcode.Error,
                    new ErrorMsg { Code = 2, Message = "already in room" }.Encode());
                return;
            }

            string roomId = $"r{++_roomSeq}";
            var players = new List<int> { pid };
            var sink = new RoomOutputSink(roomId, players, _sessions);
            var cfg = new RoomConfig { GameId = gameId, Version = version, Seed = (ulong)_roomSeq, PlayerCount = 1 };
            var infos = new[] { new PlayerInfo { PlayerId = pid, Name = $"P{pid}", IsAI = false } };

            Room room;
            try { room = _host.CreateRoom(roomId, gameId, version, cfg, infos, sink); }
            catch (Exception ex)
            {
                _logger.Error($"建房失败 {gameId}@{version}: {ex.Message}");
                SendFramework(pid, FrameworkOpcode.Error,
                    new ErrorMsg { Code = 1, Message = "create room failed" }.Encode());
                return;
            }

            s.RoomId = roomId;
            _roomPlayers[roomId] = players;

            var found = new MatchFoundMsg { RoomId = roomId, Version = version, SelfPlayerId = pid };
            found.Players.Add(new PlayerInfo { PlayerId = pid, Name = $"P{pid}", IsAI = false });
            SendFramework(pid, FrameworkOpcode.MatchFound, found.Encode());

            if (!room.IsActive) OnRoomEnded(roomId); // 极端:Start 即结束
        }

        private void OnRoomEnded(string roomId)
        {
            if (!_roomPlayers.TryGetValue(roomId, out var players)) return;
            _roomPlayers.Remove(roomId);
            var msg = new RoomEndMsg { WinnerPlayerId = 0, 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)));
        }
    }
}
  • Step 5: 运行,确认绿

Run: bash Server/build-test-games.sh && dotnet test Server/Server.sln --filter ServerLoopTests Expected: PASS5 个用例)。


Task 6: WebSocket 连接包装 Connection

Files:

  • Create: Server/Gateway/Connection.cs

收/发循环的 socket 行为由 Task 7 的真 Kestrel 集成测试覆盖(难以脱离 socket 单测)。

  • Step 1: 写 Connection.cs
using System;
using System.IO;
using System.Net.WebSockets;
using System.Threading;
using System.Threading.Tasks;
using ChannelsNS = System.Threading.Channels;
using XWorld.Framework.Protocol;

namespace XWorld.Server.Gateway
{
    // 一个 WebSocket 连接:收循环解码帧→Submit;发循环排空出站队列→SendAsync(每 socket 仅一个发循环,发送串行)
    public sealed class Connection : IClientConnection
    {
        public int PlayerId { get; }

        private readonly WebSocket _socket;
        private readonly ServerLoop _loop;
        private readonly ChannelsNS.Channel<byte[]> _outbound = ChannelsNS.Channel.CreateUnbounded<byte[]>();

        public Connection(int playerId, WebSocket socket, ServerLoop loop)
        {
            PlayerId = playerId; _socket = socket; _loop = loop;
        }

        public void Send(byte[] frame) => _outbound.Writer.TryWrite(frame);

        public async Task RunAsync(CancellationToken ct)
        {
            var sendTask = SendLoopAsync(ct);
            try { await ReceiveLoopAsync(ct).ConfigureAwait(false); }
            finally
            {
                _outbound.Writer.TryComplete();
                await sendTask.ConfigureAwait(false);
            }
        }

        private async Task ReceiveLoopAsync(CancellationToken ct)
        {
            var buf = new byte[8192];
            using var ms = new MemoryStream();
            try
            {
                while (_socket.State == WebSocketState.Open && !ct.IsCancellationRequested)
                {
                    ms.SetLength(0);
                    WebSocketReceiveResult res;
                    do
                    {
                        res = await _socket.ReceiveAsync(new ArraySegment<byte>(buf), ct).ConfigureAwait(false);
                        if (res.MessageType == WebSocketMessageType.Close) return;
                        ms.Write(buf, 0, res.Count);
                    } while (!res.EndOfMessage);

                    Frame frame;
                    try { frame = FrameCodec.Decode(ms.ToArray()); }
                    catch (FormatException) { continue; } // 畸形帧:丢弃,不拖垮连接

                    _loop.Submit(new FrameCommand { PlayerId = PlayerId, Frame = frame });
                }
            }
            catch (OperationCanceledException) { } // 主机关停/请求中止
            catch (WebSocketException) { }          // 对端突然断开(TCP 掉线)
        }

        private async Task SendLoopAsync(CancellationToken ct)
        {
            try
            {
                await foreach (var frame in _outbound.Reader.ReadAllAsync(ct).ConfigureAwait(false))
                {
                    if (_socket.State != WebSocketState.Open) break;
                    await _socket.SendAsync(new ArraySegment<byte>(frame),
                        WebSocketMessageType.Binary, true, ct).ConfigureAwait(false);
                }
            }
            catch (OperationCanceledException) { }
            catch (WebSocketException) { } // 对端已断,停止发送
        }
    }
}

Task 7: Kestrel 宿主 GameServerHost + 端到端集成测试(真 WS)

Files:

  • Create: Server/Gateway/GameServerHost.cs

  • Create: Server/Gateway.Tests/Support/WsTestClient.cs

  • Test: Server/Gateway.Tests/GatewayEndToEndTests.cs

  • Step 1: 写 GameServerHost.cs

using System;
using System.Linq;
using System.Net;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Hosting.Server;
using Microsoft.AspNetCore.Hosting.Server.Features;
using System.Net.WebSockets;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using XWorld.Server.Host;

namespace XWorld.Server.Gateway
{
    // 建 Kestrel + /ws 端点,把 socket 接入 ServerLoop。Start 返回实际监听地址(端口由系统分配)。
    public sealed class GameServerHost
    {
        private WebApplication _app;
        private ServerLoop _loop;
        private CancellationTokenSource _cts;
        private Task _loopTask;

        public string WsBaseUrl { get; private set; } // 例如 ws://127.0.0.1:54321

        public async Task StartAsync(string gamesRoot, int tickIntervalMs, long reconnectWindowTicks = 100)
        {
            _loop = new ServerLoop(gamesRoot, new ConsoleLogger("[loop]"), reconnectWindowTicks);
            _cts = new CancellationTokenSource();

            var builder = WebApplication.CreateBuilder();
            builder.Logging.ClearProviders();
            builder.WebHost.ConfigureKestrel(o => o.Listen(IPAddress.Loopback, 0)); // 临时端口

            var app = builder.Build();
            app.UseWebSockets();
            app.Use(async (ctx, next) =>
            {
                if (ctx.Request.Path == "/ws" && ctx.WebSockets.IsWebSocketRequest)
                {
                    if (!int.TryParse(ctx.Request.Query["pid"], out int pid))
                    {
                        ctx.Response.StatusCode = 400;
                        return;
                    }
                    using var ws = await ctx.WebSockets.AcceptWebSocketAsync();
                    var conn = new Connection(pid, ws, _loop);
                    _loop.Submit(new ConnectCommand { PlayerId = pid, Connection = conn });
                    try { await conn.RunAsync(ctx.RequestAborted); }
                    catch (OperationCanceledException) { } // 关停/中止:正常收束
                    finally
                    {
                        if (ws.State == WebSocketState.Open)
                        {
                            try { await ws.CloseAsync(WebSocketCloseStatus.NormalClosure, "server stopping", CancellationToken.None); }
                            catch { /* 关闭握手尽力而为 */ }
                        }
                        _loop.Submit(new DisconnectCommand { PlayerId = pid, Connection = conn });
                    }
                }
                else
                {
                    await next();
                }
            });

            await app.StartAsync();
            _app = app;

            var addresses = app.Services.GetRequiredService<IServer>()
                .Features.Get<IServerAddressesFeature>().Addresses;
            string http = addresses.FirstOrDefault() // http://127.0.0.1:port
                ?? throw new InvalidOperationException("Kestrel 已启动但未注册任何监听地址");
            WsBaseUrl = http.Replace("http://", "ws://");

            _loopTask = _loop.RunAsync(tickIntervalMs, _cts.Token);
        }

        public async Task StopAsync()
        {
            _cts.Cancel();
            try { await _loopTask; } catch { /* 忽略取消 */ }
            await _app.StopAsync();
            await _app.DisposeAsync();
        }
    }
}
  • Step 2: 写 Support/WsTestClient.cs
using System;
using System.IO;
using System.Net.WebSockets;
using System.Threading;
using System.Threading.Tasks;
using XWorld.Framework.Protocol;

namespace XWorld.Server.Gateway.Tests
{
    // 集成测试用 WS 客户端:连接、发帧、带超时收一帧并解码
    internal sealed class WsTestClient : IDisposable
    {
        private readonly ClientWebSocket _ws = new ClientWebSocket();

        public async Task ConnectAsync(string wsBaseUrl, int pid)
        {
            await _ws.ConnectAsync(new Uri($"{wsBaseUrl}/ws?pid={pid}"), CancellationToken.None);
        }

        public async Task SendFrameAsync(Frame frame)
        {
            byte[] bytes = FrameCodec.Encode(frame);
            await _ws.SendAsync(new ArraySegment<byte>(bytes), WebSocketMessageType.Binary, true, CancellationToken.None);
        }

        // 在 timeout 内收一条 WS 消息并解码为 Frame;超时抛 TimeoutException
        public async Task<Frame> ReceiveFrameAsync(int timeoutMs = 5000)
        {
            using var cts = new CancellationTokenSource(timeoutMs);
            var buf = new byte[8192];
            using var ms = new MemoryStream();
            WebSocketReceiveResult res;
            do
            {
                res = await _ws.ReceiveAsync(new ArraySegment<byte>(buf), cts.Token);
                if (res.MessageType == WebSocketMessageType.Close)
                    throw new InvalidOperationException("server closed");
                ms.Write(buf, 0, res.Count);
            } while (!res.EndOfMessage);
            return FrameCodec.Decode(ms.ToArray());
        }

        public async Task CloseAsync()
        {
            try { await _ws.CloseAsync(WebSocketCloseStatus.NormalClosure, "bye", CancellationToken.None); }
            catch { /* 忽略 */ }
        }

        public void Dispose() => _ws.Dispose();
    }
}
  • Step 3: 写集成测试 GatewayEndToEndTests.cs
using System.Collections.Generic;
using System.Threading.Tasks;
using Xunit;
using XWorld.Framework.Protocol;
using XWorld.Server.Gateway;

namespace XWorld.Server.Gateway.Tests
{
    public class GatewayEndToEndTests
    {
        private static Frame JoinHello()
            => new Frame(Channel.Framework, (ushort)FrameworkOpcode.MatchRequest,
                new MatchRequestMsg { GameId = "hello", Version = 1 }.Encode());

        [Fact]
        public async Task Connect_Join_ReceivesMatchFound_Snapshots_RoomEnd()
        {
            var host = new GameServerHost();
            await host.StartAsync(TestGames.Root, tickIntervalMs: 20);
            try
            {
                using var client = new WsTestClient();
                await client.ConnectAsync(host.WsBaseUrl, pid: 1);
                await client.SendFrameAsync(JoinHello());

                // 收帧直到 RoomEnd
                bool matchFound = false, roomEnd = false;
                int snapshots = 0;
                for (int i = 0; i < 20 && !roomEnd; i++)
                {
                    var f = await client.ReceiveFrameAsync(5000);
                    if (f.Channel == Channel.Framework && f.Opcode == (ushort)FrameworkOpcode.MatchFound) matchFound = true;
                    else if (f.Channel == Channel.Game) snapshots++;
                    else if (f.Channel == Channel.Framework && f.Opcode == (ushort)FrameworkOpcode.RoomEnd) roomEnd = true;
                }

                Assert.True(matchFound, "未收到 MatchFound");
                Assert.True(snapshots >= 3, $"快照数应≥3,实际 {snapshots}");
                Assert.True(roomEnd, "未收到 RoomEnd");
                await client.CloseAsync();
            }
            finally { await host.StopAsync(); }
        }

        [Fact]
        public async Task Heartbeat_IsEchoedBack()
        {
            var host = new GameServerHost();
            await host.StartAsync(TestGames.Root, tickIntervalMs: 20);
            try
            {
                using var client = new WsTestClient();
                await client.ConnectAsync(host.WsBaseUrl, pid: 7);
                await client.SendFrameAsync(new Frame(Channel.Framework, (ushort)FrameworkOpcode.Heartbeat,
                    new HeartbeatMsg { ClientTimeMs = 999L }.Encode()));

                Frame? echo = null; // Frame 是 readonly struct,用可空值类型
                for (int i = 0; i < 10; i++)
                {
                    var f = await client.ReceiveFrameAsync(5000);
                    if (f.Channel == Channel.Framework && f.Opcode == (ushort)FrameworkOpcode.Heartbeat) { echo = f; break; }
                }
                Assert.True(echo.HasValue);
                Assert.Equal(999L, HeartbeatMsg.Decode(echo.Value.Payload).ClientTimeMs);
                await client.CloseAsync();
            }
            finally { await host.StopAsync(); }
        }

        [Fact]
        public async Task Reconnect_ContinuesReceivingFromSameRoom()
        {
            var host = new GameServerHost();
            // 大 tick 间隔给重连留足时间(Hello 3 tick = 3s
            await host.StartAsync(TestGames.Root, tickIntervalMs: 1000, reconnectWindowTicks: 100);
            try
            {
                var client1 = new WsTestClient();
                await client1.ConnectAsync(host.WsBaseUrl, pid: 42);
                await client1.SendFrameAsync(JoinHello());

                // 收到 MatchFound + 第一个快照(首个 tick 在加入后约 ≤1s 触发)
                bool gotSnapshot = false;
                for (int i = 0; i < 5 && !gotSnapshot; i++)
                {
                    var f = await client1.ReceiveFrameAsync(5000);
                    if (f.Channel == Channel.Game) gotSnapshot = true;
                }
                Assert.True(gotSnapshot, "重连前应先收到一个快照");

                // 断开
                await client1.CloseAsync();
                client1.Dispose();

                // 用同 pid 重连
                using var client2 = new WsTestClient();
                await client2.ConnectAsync(host.WsBaseUrl, pid: 42);

                // 房间仍在跑,下一个快照应发到 client2
                bool gotAfterReconnect = false;
                for (int i = 0; i < 5 && !gotAfterReconnect; i++)
                {
                    var f = await client2.ReceiveFrameAsync(5000);
                    if (f.Channel == Channel.Game) gotAfterReconnect = true;
                    else if (f.Channel == Channel.Framework && f.Opcode == (ushort)FrameworkOpcode.RoomEnd) { gotAfterReconnect = true; }
                }
                Assert.True(gotAfterReconnect, "重连后应继续收到房间输出(快照或 RoomEnd)");
                await client2.CloseAsync();
            }
            finally { await host.StopAsync(); }
        }
    }
}
  • Step 4: 运行集成测试,确认绿

Run: bash Server/build-test-games.sh && dotnet test Server/Server.sln --filter GatewayEndToEndTests Expected: 3 个集成测试 PASS(真 Kestrel + ClientWebSocket,端到端连/入房/收快照/结束、心跳回显、断线重连续收)。

Connect_Join_... 偶发超时:确认 tickIntervalMs 较小(20ms),并发循环 RunAsync 在首个 Task.Delay 前已 DrainAndTick 一次。若重连测试偶发:增大 tickIntervalMs(如 1500)给客户端重连更多余量。真实端口/真实 WS 帧不应有功能性失败。


Task 8: 收尾——全量验证

  • Step 1: 生成夹具并跑全量

Run:

bash Server/build-test-games.sh
dotnet test Server/Server.sln

Expected: 全绿。= 子项目 146+ 2a27 + RoomHost 新增 1 = 28+ 2b-1SessionManager 5 + RoomOutputSink 3 + ServerLoop 5 + GatewayEndToEnd 3 = 16)。失败 0。

若遇 MSBuild 工作进程在沙箱中 0xC0000005 偶发崩溃(与产品无关),改用 DOTNET_CLI_USE_MSBUILD_SERVER=0 dotnet test Server/Server.sln -m:1 重试。

  • Step 2: 构建零警告确认

Run: dotnet build Server/Server.sln -v minimal Expected: Build succeeded0 error / 0 warning。


完成判据(Definition of Done

  • bash Server/build-test-games.sh && dotnet test Server/Server.sln 全绿(含 16 个新测试)。
  • dotnet build Server/Server.sln 0 error / 0 warning。
  • 真 WS 端到端:客户端经真 Kestrel + ClientWebSocket 连接 → 发 MatchRequest → 收 MatchFound → 收 ≥3 个 Game 通道快照 → 收 RoomEnd。
  • 路由正确:Framework 通道帧走框架处理(MatchRequest/Heartbeat),Game 通道帧路由到玩家所在房间。
  • 心跳回显;断线重连窗口内续收同一房间输出(输出重路由到新连接);过期会话被扫除。
  • 并发模型:网络线程只 Submit(线程安全 Channel),RoomHost/Session 仅由 actor 单线程在 DrainAndTick 内访问,无锁。
  • 共享层(Framework.Shared)零改动;2a 仅 RoomHost.TickAll 加返回值(向后兼容)。

不在本子项目范围(交给后续)

  • 匹配 + AI 兜底(按 (gameId,version) 分桶、凑齐 N 人、15s 超时→AI 兜底)+ 多人/2 人游戏——随 **RPS 重写(§9 第 5 步)**一起做(届时有真正的 2 人游戏与 AI 规则)。
  • 鉴权(当前 ?pid= 是明文身份占位)、WSS/TLS、限流、压测。
  • 持久化升级(替换 InMemoryStorage)、监控告警、发布通知订阅。
  • IGameLogicTInput/TEvent 统一编解码约定与“业务输入走 Channel.Game vs GameInputMsg”的最终定夺——本计划已用 Channel.Game 直传业务帧(NetMessage(frame.Opcode, frame.Payload)),RPS 重写时确认沿用。
  • minFrameworkVersion/tickRateHz 的加载期校验与按 manifest 驱动 tick(当前 tick 间隔由宿主参数给定)。