# 服务端 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=` 端点。每个连接跑收/发两个异步循环:收到的 WS 消息用 `FrameCodec` 解码后**只把命令入队**(不碰 RoomHost);发送侧有独立出站队列。一个 `ServerLoop`(actor)独占 RoomHost 与 SessionManager:它排空命令队列、按 tickRateHz 调 `RoomHost.TickAll`、把房间广播经 `IRoomOutput` 汇成 Game 通道帧塞回各玩家连接、并扫描重连窗口过期会话。actor 暴露 `Submit(cmd)`(线程安全)与 `DrainAndTick(dt)`(测试直调;真实循环周期性调它),使路由/房间生命周期逻辑可确定性单测,真 socket 管道走真 Kestrel 集成测试。 **Tech Stack:** C# / net10.0 · ASP.NET Core Kestrel WebSockets(`FrameworkReference 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 步的网络前端部分(**传输核心**)。 - 依赖:子项目 1(`Server/Framework.Shared`,契约 + 协议)与子项目 2a(`Server/Server.Host`,`RoomHost`/`Room`/`GameModuleRegistry`/`GameModuleLoader`/`RoomCtx`/`IRoomOutput`/能力实现)均已完成,`dotnet test Server/Server.sln` 当前 73/73。 - **无 git**:跳过所有 git 步骤,其余照常。 - 工作目录 `D:/UD/AI/AIC#Project`,bash on Windows(正斜杠、`/dev/null`)。`dotnet` SDK 10.0.201(net10 自带 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.Gateway`,net10.0,`FrameworkReference Microsoft.AspNetCore.App` + 引用 `Server.Host`。 - 命名冲突处理:协议帧通道枚举是 `XWorld.Framework.Protocol.Channel`;`System.Threading.Channels.Channel` 用别名 `using ChannelsNS = System.Threading.Channels;` 引入,写作 `ChannelsNS.Channel`,而 `Channel.Framework`/`Channel.Game` 指协议枚举(`using XWorld.Framework.Protocol;`)。 ## 文件结构 网关库 `Server/Gateway/`: | 文件 | 职责 | |---|---| | `Gateway.csproj` | net10.0,FrameworkReference ASP.NET Core,引用 Server.Host | | `IClientConnection.cs` | 出站抽象:`int PlayerId`、`void 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 + Session;Submit/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` 类内)** ```csharp [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` 当前返回 void,`ended = host.TickAll(...)` 无法赋值)。 - [ ] **Step 3: 修改 `RoomHost.TickAll` 返回类型** 把 `RoomHost.cs` 中的 `TickAll` 方法整体替换为: ```csharp public IReadOnlyList TickAll(float dt) { List ended = null; foreach (var kv in _rooms) { var room = kv.Value.Room; if (!room.IsActive) { (ended ??= new List()).Add(kv.Key); continue; } room.Tick(dt); if (!room.IsActive) (ended ??= new List()).Add(kv.Key); } if (ended != null) foreach (var id in ended) ReleaseRoom(id); return ended ?? (IReadOnlyList)System.Array.Empty(); } ``` 其余方法不变。(旧测试 `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: 生成工程并并入解决方案** ```bash 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`** ```xml net10.0 disable disable XWorld.Server.Gateway XWorld.Server.Gateway ``` - [ ] **Step 3: 配置 `Server/Gateway.Tests/Gateway.Tests.csproj`** 在 `` 前加入(模板已含 xUnit 包;补 `Nullable disable`、引用、TestGames 内容复用)。先把 `` 里加一行 `disable`(若模板默认 enable),再加: ```xml PreserveNewest TestGames\%(RecursiveDir)%(Filename)%(Extension) ``` - [ ] **Step 4: 写 `Server/Gateway.Tests/Support/TestGames.cs`** ```csharp 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 succeeded`,0 error。然后 `ls "Server/Gateway.Tests/bin/Debug/net10.0/TestGames/hello/1"` 列出 `Hello.Core.dll`、`Hello.Server.dll`、`game.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`** ```csharp using System.Collections.Generic; namespace XWorld.Server.Gateway.Tests { internal sealed class FakeConnection : IClientConnection { public int PlayerId { get; } public List Sent { get; } = new List(); public FakeConnection(int playerId) { PlayerId = playerId; } public void Send(byte[] frame) => Sent.Add(frame); } } ``` - [ ] **Step 2: 写失败测试 `SessionManagerTests.cs`** ```csharp 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`** ```csharp namespace XWorld.Server.Gateway { // actor 把出站帧交给它(非阻塞入队);生产实现写 WebSocket 出站队列,测试用假实现记录 public interface IClientConnection { int PlayerId { get; } // frame 在调用后不会被修改:实现可保留该引用(广播会把同一数组发给多个连接)。 void Send(byte[] frame); } } ``` - [ ] **Step 5: 写 `Session.cs`** ```csharp 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`** ```csharp using System; using System.Collections.Generic; namespace XWorld.Server.Gateway { // pid -> Session。只在 actor 线程被改,故无锁。 public sealed class SessionManager { private readonly Dictionary _byPid = new Dictionary(); 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; /// 移除断线且“严格越过”重连窗口的会话(currentTick - DisconnectedAtTick > windowTicks)。返回被移除的会话;无则空数组。 public IReadOnlyList SweepExpired(long currentTick, long windowTicks) { List removed = null; foreach (var kv in _byPid) { var s = kv.Value; if (!s.Connected && currentTick - s.DisconnectedAtTick > windowTicks) (removed ??= new List()).Add(s); } if (removed != null) foreach (var s in removed) _byPid.Remove(s.PlayerId); return removed ?? (IReadOnlyList)Array.Empty(); } } } ``` - [ ] **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`** ```csharp 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 { 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 { 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 { 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 { 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`** ```csharp 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 _players; private readonly SessionManager _sessions; public RoomOutputSink(string roomId, IReadOnlyList 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`** ```csharp 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 Frames(FakeConnection c) { var list = new List(); 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 个 MatchFound(Framework)+ 3 个快照(Game)+ 1 个 RoomEnd(Framework) 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 起始 0,3 次 +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); // tick1:c1 收到 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→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 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); // 入房 r1(Hello 第 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`** ```csharp 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`** ```csharp 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 _cmds = ChannelsNS.Channel.CreateUnbounded(); private readonly Dictionary> _roomPlayers = new Dictionary>(); 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 { 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[] 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: PASS(5 个用例)。 --- ## Task 6: WebSocket 连接包装 Connection **Files:** - Create: `Server/Gateway/Connection.cs` > 收/发循环的 socket 行为由 Task 7 的真 Kestrel 集成测试覆盖(难以脱离 socket 单测)。 - [ ] **Step 1: 写 `Connection.cs`** ```csharp 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 _outbound = ChannelsNS.Channel.CreateUnbounded(); 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(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(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`** ```csharp 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() .Features.Get().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`** ```csharp 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(bytes), WebSocketMessageType.Binary, true, CancellationToken.None); } // 在 timeout 内收一条 WS 消息并解码为 Frame;超时抛 TimeoutException public async Task 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(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`** ```csharp 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 bash Server/build-test-games.sh dotnet test Server/Server.sln ``` Expected: 全绿。= 子项目 1(46)+ 2a(27 + RoomHost 新增 1 = 28)+ 2b-1(SessionManager 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 succeeded`,0 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`)、监控告警、发布通知订阅。 - `IGameLogic` 的 `TInput`/`TEvent` 统一编解码约定与“业务输入走 Channel.Game vs GameInputMsg”的最终定夺——本计划已用 `Channel.Game` 直传业务帧(`NetMessage(frame.Opcode, frame.Payload)`),RPS 重写时确认沿用。 - `minFrameworkVersion`/`tickRateHz` 的加载期校验与按 manifest 驱动 tick(当前 tick 间隔由宿主参数给定)。