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

1346 lines
53 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 服务端 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);发送侧有独立出站队列。一个 `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.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.Gateway`net10.0`FrameworkReference Microsoft.AspNetCore.App` + 引用 `Server.Host`
- 命名冲突处理:协议帧通道枚举是 `XWorld.Framework.Protocol.Channel``System.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 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 + 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` 类内)**
```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<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: 生成工程并并入解决方案**
```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
<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),再加:
```xml
<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`**
```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<byte[]> Sent { get; } = new List<byte[]>();
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<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`**
```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<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`**
```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<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`**
```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<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`**
```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<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`**
```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<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`**
```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<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`**
```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<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`**
```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: 全绿。= 子项目 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 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 间隔由宿主参数给定)。