1346 lines
53 KiB
Markdown
1346 lines
53 KiB
Markdown
# 服务端 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.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<T>`,而 `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<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 > 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 个 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<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: 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<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: 全绿。= 子项目 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 间隔由宿主参数给定)。
|