53 KiB
服务端 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)。dotnetSDK 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类内)
[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 方法整体替换为:
public IReadOnlyList<string> TickAll(float dt)
{
List<string> ended = null;
foreach (var kv in _rooms)
{
var room = kv.Value.Room;
if (!room.IsActive) { (ended ??= new List<string>()).Add(kv.Key); continue; }
room.Tick(dt);
if (!room.IsActive) (ended ??= new List<string>()).Add(kv.Key);
}
if (ended != null)
foreach (var id in ended) ReleaseRoom(id);
return ended ?? (IReadOnlyList<string>)System.Array.Empty<string>();
}
其余方法不变。(旧测试 host.TickAll(1f) 忽略返回值,仍编译通过。)
- Step 4: 运行,确认绿
Run: bash Server/build-test-games.sh && dotnet test Server/Server.sln --filter RoomHostTests
Expected: PASS(含新用例)。
Task 2: 搭建网关项目与测试工程
Files:
-
Create:
Server/Gateway/Gateway.csproj -
Create:
Server/Gateway.Tests/Gateway.Tests.csproj -
Create:
Server/Gateway.Tests/Support/TestGames.cs -
Modify:
Server/Server.sln -
Step 1: 生成工程并并入解决方案
dotnet new classlib -n Gateway -o Server/Gateway -f net10.0
dotnet new xunit -n Gateway.Tests -o Server/Gateway.Tests
rm -f Server/Gateway/Class1.cs Server/Gateway.Tests/UnitTest1.cs
dotnet sln Server/Server.sln add Server/Gateway/Gateway.csproj
dotnet sln Server/Server.sln add Server/Gateway.Tests/Gateway.Tests.csproj
mkdir -p Server/Gateway.Tests/Support
- Step 2: 写
Server/Gateway/Gateway.csproj
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>disable</Nullable>
<ImplicitUsings>disable</ImplicitUsings>
<AssemblyName>XWorld.Server.Gateway</AssemblyName>
<RootNamespace>XWorld.Server.Gateway</RootNamespace>
</PropertyGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
<ProjectReference Include="..\Server.Host\Server.Host.csproj" />
</ItemGroup>
</Project>
- Step 3: 配置
Server/Gateway.Tests/Gateway.Tests.csproj
在 </Project> 前加入(模板已含 xUnit 包;补 Nullable disable、引用、TestGames 内容复用)。先把 <PropertyGroup> 里加一行 <Nullable>disable</Nullable>(若模板默认 enable),再加:
<ItemGroup>
<ProjectReference Include="..\Gateway\Gateway.csproj" />
</ItemGroup>
<ItemGroup>
<!-- 复用 2a 已构建的 Hello 夹具(由 build-test-games.sh 生成到 Server.Host.Tests/TestGames) -->
<Content Include="..\Server.Host.Tests\TestGames\**\*">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<Link>TestGames\%(RecursiveDir)%(Filename)%(Extension)</Link>
</Content>
</ItemGroup>
- Step 4: 写
Server/Gateway.Tests/Support/TestGames.cs
using System;
using System.IO;
namespace XWorld.Server.Gateway.Tests
{
internal static class TestGames
{
// 前置条件:先运行 build-test-games.sh 生成 Server.Host.Tests/TestGames(本工程复用之)
public static string Root => Path.Combine(AppContext.BaseDirectory, "TestGames");
}
}
- Step 5: 构建确认(夹具须先生成)
Run: bash Server/build-test-games.sh && dotnet build Server/Server.sln -v minimal
Expected: Build 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
using System.Collections.Generic;
namespace XWorld.Server.Gateway.Tests
{
internal sealed class FakeConnection : IClientConnection
{
public int PlayerId { get; }
public List<byte[]> Sent { get; } = new List<byte[]>();
public FakeConnection(int playerId) { PlayerId = playerId; }
public void Send(byte[] frame) => Sent.Add(frame);
}
}
- Step 2: 写失败测试
SessionManagerTests.cs
using Xunit;
using XWorld.Server.Gateway;
namespace XWorld.Server.Gateway.Tests
{
public class SessionManagerTests
{
[Fact]
public void Connect_NewPid_CreatesConnectedSession()
{
var sm = new SessionManager();
var c = new FakeConnection(1);
sm.OnConnect(1, c);
Assert.True(sm.IsConnected(1));
Assert.Same(c, sm.GetConnection(1));
}
[Fact]
public void Reconnect_SamePid_RebindsToNewConnection()
{
var sm = new SessionManager();
var c1 = new FakeConnection(1);
var c2 = new FakeConnection(1);
sm.OnConnect(1, c1);
sm.OnDisconnect(1, c1, currentTick: 5);
Assert.False(sm.IsConnected(1));
Assert.Null(sm.GetConnection(1));
sm.OnConnect(1, c2); // 重连
Assert.True(sm.IsConnected(1));
Assert.Same(c2, sm.GetConnection(1)); // 路由到新连接
}
[Fact]
public void StaleDisconnect_AfterReconnect_DoesNotClobber()
{
var sm = new SessionManager();
var c1 = new FakeConnection(1);
var c2 = new FakeConnection(1);
sm.OnConnect(1, c1);
sm.OnConnect(1, c2); // 已重连到 c2
sm.OnDisconnect(1, c1, currentTick: 9); // 旧连接 c1 的迟到断线不应清掉 c2
Assert.True(sm.IsConnected(1));
Assert.Same(c2, sm.GetConnection(1));
}
[Fact]
public void RoomBinding_RoundTrips()
{
var sm = new SessionManager();
sm.OnConnect(1, new FakeConnection(1));
sm.Get(1).RoomId = "r1";
Assert.Equal("r1", sm.Get(1).RoomId);
}
[Fact]
public void SweepExpired_RemovesSessionsPastWindow()
{
var sm = new SessionManager();
var conn = new FakeConnection(1);
sm.OnConnect(1, conn);
sm.OnDisconnect(1, conn, currentTick: 0);
var none = sm.SweepExpired(currentTick: 3, windowTicks: 5);
Assert.Empty(none);
Assert.NotNull(sm.Get(1));
var removed = sm.SweepExpired(currentTick: 6, windowTicks: 5); // 6-0 > 5
Assert.Single(removed);
Assert.Null(sm.Get(1));
}
}
}
- Step 3: 运行,确认红
Run: dotnet test Server/Server.sln --filter SessionManagerTests
Expected: 编译失败(相关类型不存在)。
- Step 4: 写
IClientConnection.cs
namespace XWorld.Server.Gateway
{
// actor 把出站帧交给它(非阻塞入队);生产实现写 WebSocket 出站队列,测试用假实现记录
public interface IClientConnection
{
int PlayerId { get; }
// frame 在调用后不会被修改:实现可保留该引用(广播会把同一数组发给多个连接)。
void Send(byte[] frame);
}
}
- Step 5: 写
Session.cs
namespace XWorld.Server.Gateway
{
public sealed class Session
{
public int PlayerId;
public IClientConnection Connection; // 当前连接;断线中仍保留会话但 Connected=false
public bool Connected;
public string RoomId; // 所在房间,可空
public long DisconnectedAtTick; // 断线时的逻辑 tick,用于窗口过期
}
}
- Step 6: 写
SessionManager.cs
using System;
using System.Collections.Generic;
namespace XWorld.Server.Gateway
{
// pid -> Session。只在 actor 线程被改,故无锁。
public sealed class SessionManager
{
private readonly Dictionary<int, Session> _byPid = new Dictionary<int, Session>();
public Session OnConnect(int pid, IClientConnection conn)
{
if (_byPid.TryGetValue(pid, out var s))
{
s.Connection = conn; // 重连:重绑新连接,保留 RoomId
s.Connected = true;
return s;
}
s = new Session { PlayerId = pid, Connection = conn, Connected = true };
_byPid[pid] = s;
return s;
}
public void OnDisconnect(int pid, IClientConnection conn, long currentTick)
{
// 仅当断的是“当前连接”才标离线,避免旧连接的迟到断线清掉已重连的新连接
if (_byPid.TryGetValue(pid, out var s) && ReferenceEquals(s.Connection, conn))
{
s.Connected = false;
s.DisconnectedAtTick = currentTick;
}
}
public IClientConnection GetConnection(int pid)
=> _byPid.TryGetValue(pid, out var s) && s.Connected ? s.Connection : null;
public bool IsConnected(int pid) => _byPid.TryGetValue(pid, out var s) && s.Connected;
public Session Get(int pid) => _byPid.TryGetValue(pid, out var s) ? s : null;
/// <summary>移除断线且“严格越过”重连窗口的会话(currentTick - DisconnectedAtTick > windowTicks)。返回被移除的会话;无则空数组。</summary>
public IReadOnlyList<Session> SweepExpired(long currentTick, long windowTicks)
{
List<Session> removed = null;
foreach (var kv in _byPid)
{
var s = kv.Value;
if (!s.Connected && currentTick - s.DisconnectedAtTick > windowTicks)
(removed ??= new List<Session>()).Add(s);
}
if (removed != null)
foreach (var s in removed) _byPid.Remove(s.PlayerId);
return removed ?? (IReadOnlyList<Session>)Array.Empty<Session>();
}
}
}
- Step 7: 运行,确认绿
Run: dotnet test Server/Server.sln --filter SessionManagerTests
Expected: PASS。
Task 4: 房间出站汇 RoomOutputSink(单元 TDD)
Files:
-
Create:
Server/Gateway/RoomOutputSink.cs -
Test:
Server/Gateway.Tests/RoomOutputSinkTests.cs -
Step 1: 写失败测试
RoomOutputSinkTests.cs
using System.Collections.Generic;
using Xunit;
using XWorld.Framework;
using XWorld.Framework.Protocol;
using XWorld.Server.Gateway;
namespace XWorld.Server.Gateway.Tests
{
public class RoomOutputSinkTests
{
[Fact]
public void Broadcast_WrapsInGameChannelFrame_ToConnectedPlayers()
{
var sm = new SessionManager();
var c1 = new FakeConnection(1);
var c2 = new FakeConnection(2);
sm.OnConnect(1, c1);
sm.OnConnect(2, c2);
var sink = new RoomOutputSink("r1", new List<int> { 1, 2 }, sm);
sink.Broadcast(new NetMessage(7, new byte[] { 9, 9 }));
Assert.Single(c1.Sent);
Assert.Single(c2.Sent);
var f = FrameCodec.Decode(c1.Sent[0]);
Assert.Equal(Channel.Game, f.Channel);
Assert.Equal((ushort)7, f.Opcode);
Assert.Equal(new byte[] { 9, 9 }, f.Payload);
}
[Fact]
public void Broadcast_SkipsDisconnectedPlayer()
{
var sm = new SessionManager();
var c1 = new FakeConnection(1);
sm.OnConnect(1, c1);
sm.OnConnect(2, new FakeConnection(2));
sm.OnDisconnect(2, sm.GetConnection(2), 0); // 玩家2 离线
var sink = new RoomOutputSink("r1", new List<int> { 1, 2 }, sm);
sink.Broadcast(new NetMessage(1, new byte[0]));
Assert.Single(c1.Sent); // 仅在线玩家收到,不抛异常
}
[Fact]
public void SendTo_OnlyTargetPlayer()
{
var sm = new SessionManager();
var c1 = new FakeConnection(1);
var c2 = new FakeConnection(2);
sm.OnConnect(1, c1);
sm.OnConnect(2, c2);
var sink = new RoomOutputSink("r1", new List<int> { 1, 2 }, sm);
sink.SendTo(2, new NetMessage(5, new byte[] { 1 }));
Assert.Empty(c1.Sent);
Assert.Single(c2.Sent);
}
[Fact]
public void Broadcast_AfterReconnect_GoesToNewConnection()
{
var sm = new SessionManager();
var c1 = new FakeConnection(1);
sm.OnConnect(1, c1);
var sink = new RoomOutputSink("r1", new List<int> { 1 }, sm);
sink.Broadcast(new NetMessage(1, new byte[] { 1 }));
Assert.Single(c1.Sent);
sm.OnDisconnect(1, c1, 0); // 断线
var c2 = new FakeConnection(1);
sm.OnConnect(1, c2); // 重连到新连接
sink.Broadcast(new NetMessage(1, new byte[] { 2 }));
Assert.Single(c1.Sent); // 旧连接不再收到
Assert.Single(c2.Sent); // 输出跟随重连到新连接
}
}
}
- Step 2: 运行,确认红
Run: dotnet test Server/Server.sln --filter RoomOutputSinkTests
Expected: 编译失败(RoomOutputSink 不存在)。
- Step 3: 写
RoomOutputSink.cs
using System.Collections.Generic;
using XWorld.Framework;
using XWorld.Framework.Protocol;
namespace XWorld.Server.Gateway
{
// 房间广播 → 解析各玩家当前连接(重连后自动指向新连接)→ 包成 Game 通道帧入对应出站队列
public sealed class RoomOutputSink : IRoomOutput
{
private readonly string _roomId; // 保留作诊断/日志标识
private readonly IReadOnlyList<int> _players;
private readonly SessionManager _sessions;
public RoomOutputSink(string roomId, IReadOnlyList<int> players, SessionManager sessions)
{
_roomId = roomId; _players = players; _sessions = sessions;
}
public void Broadcast(NetMessage message)
{
byte[] frame = FrameCodec.Encode(new Frame(Channel.Game, message.Opcode, message.Payload));
foreach (var pid in _players)
_sessions.GetConnection(pid)?.Send(frame);
}
public void SendTo(int playerId, NetMessage message)
{
var conn = _sessions.GetConnection(playerId);
if (conn != null)
conn.Send(FrameCodec.Encode(new Frame(Channel.Game, message.Opcode, message.Payload)));
}
}
}
- Step 4: 运行,确认绿
Run: dotnet test Server/Server.sln --filter RoomOutputSinkTests
Expected: PASS。
Task 5: actor 核心 ServerLoop(单元 TDD,含路由/房间生命周期/心跳/重连)
Files:
- Create:
Server/Gateway/ServerCommand.cs - Create:
Server/Gateway/ServerLoop.cs - Test:
Server/Gateway.Tests/ServerLoopTests.cs
用
DrainAndTick(dt)直接驱动,配合FakeConnection+ 真 Hello 房间(从 TestGames 加载),无需任何 socket,确定性测试 actor 逻辑。
- Step 1: 写失败测试
ServerLoopTests.cs
using System.Collections.Generic;
using Xunit;
using XWorld.Framework;
using XWorld.Framework.Protocol;
using XWorld.Server.Host;
using XWorld.Server.Gateway;
namespace XWorld.Server.Gateway.Tests
{
public class ServerLoopTests
{
private static ServerLoop NewLoop(long reconnectWindow = 100)
=> new ServerLoop(TestGames.Root, new ConsoleLogger("[loop]"), reconnectWindow);
private static byte[] JoinFrame(string gameId, int version)
=> FrameCodec.Encode(new Frame(Channel.Framework, (ushort)FrameworkOpcode.MatchRequest,
new MatchRequestMsg { GameId = gameId, Version = version }.Encode()));
// 把假连接收到的帧按 (channel, opcode) 分类
private static List<Frame> Frames(FakeConnection c)
{
var list = new List<Frame>();
foreach (var bytes in c.Sent) list.Add(FrameCodec.Decode(bytes));
return list;
}
private static int LastGameCounter(FakeConnection c)
{
int counter = -1;
foreach (var f in Frames(c))
if (f.Channel == Channel.Game)
{
var r = new PacketReader(f.Payload);
r.ReadVarInt(); // tick
counter = r.ReadVarInt(); // counter
}
return counter;
}
[Fact]
public void Join_RepliesMatchFound_Snapshots_ThenRoomEnd()
{
var loop = NewLoop();
var c = new FakeConnection(1);
loop.Submit(new ConnectCommand { PlayerId = 1, Connection = c });
loop.Submit(new FrameCommand { PlayerId = 1, Frame = FrameCodec.Decode(JoinFrame("hello", 1)) });
for (int i = 0; i < 3; i++) loop.DrainAndTick(1f); // Hello 第 3 tick 结束
var frames = Frames(c);
// 1 个 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
using XWorld.Framework.Protocol;
namespace XWorld.Server.Gateway
{
public abstract class ServerCommand { public int PlayerId; }
public sealed class ConnectCommand : ServerCommand { public IClientConnection Connection; }
public sealed class DisconnectCommand : ServerCommand { public IClientConnection Connection; }
public sealed class FrameCommand : ServerCommand { public Frame Frame; }
}
- Step 4: 写
ServerLoop.cs
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using ChannelsNS = System.Threading.Channels;
using XWorld.Framework;
using XWorld.Framework.Protocol;
using XWorld.Server.Host;
namespace XWorld.Server.Gateway
{
// 单线程 actor:独占 RoomHost 与 SessionManager;命令入队、周期性排空+推进。
public sealed class ServerLoop
{
private readonly RoomHost _host;
private readonly SessionManager _sessions = new SessionManager();
private readonly ILogger _logger;
private readonly long _reconnectWindowTicks;
private readonly ChannelsNS.Channel<ServerCommand> _cmds =
ChannelsNS.Channel.CreateUnbounded<ServerCommand>();
private readonly Dictionary<string, List<int>> _roomPlayers = new Dictionary<string, List<int>>();
private long _tick;
private int _roomSeq;
public ServerLoop(string gamesRoot, ILogger logger, long reconnectWindowTicks = 100)
{
_logger = logger;
_reconnectWindowTicks = reconnectWindowTicks;
_host = new RoomHost(new GameModuleRegistry(new GameModuleLoader(), gamesRoot), logger);
}
// 线程安全:网络线程调用,仅入队
public void Submit(ServerCommand cmd) => _cmds.Writer.TryWrite(cmd);
// 仅 actor 线程调用(_sessions 无锁);测试在单线程下使用
public bool HasSession(int pid) => _sessions.Get(pid) != null;
// 测试直调;真实循环周期性调它。先排空命令,再推进一 tick,处理结束房间与过期会话。
public void DrainAndTick(float dt)
{
while (_cmds.Reader.TryRead(out var cmd)) Process(cmd);
_tick++;
var ended = _host.TickAll(dt);
for (int i = 0; i < ended.Count; i++) OnRoomEnded(ended[i]);
var expired = _sessions.SweepExpired(_tick, _reconnectWindowTicks);
for (int i = 0; i < expired.Count; i++)
_logger.Info($"session {expired[i].PlayerId} 重连窗口过期,清除");
}
public async Task RunAsync(int tickIntervalMs, CancellationToken ct)
{
float dt = tickIntervalMs / 1000f;
while (!ct.IsCancellationRequested)
{
DrainAndTick(dt);
try { await Task.Delay(tickIntervalMs, ct).ConfigureAwait(false); }
catch (OperationCanceledException) { break; }
}
}
private void Process(ServerCommand cmd)
{
switch (cmd)
{
case ConnectCommand cc: _sessions.OnConnect(cc.PlayerId, cc.Connection); break;
case DisconnectCommand dc: _sessions.OnDisconnect(dc.PlayerId, dc.Connection, _tick); break;
case FrameCommand fc: Route(fc.PlayerId, fc.Frame); break;
}
}
private void Route(int pid, Frame frame)
{
if (frame.Channel == Channel.Framework) HandleFramework(pid, frame);
else
{
var s = _sessions.Get(pid);
if (s?.RoomId != null)
_host.DeliverTo(s.RoomId, pid, new NetMessage(frame.Opcode, frame.Payload));
}
}
private void HandleFramework(int pid, Frame frame)
{
switch ((FrameworkOpcode)frame.Opcode)
{
case FrameworkOpcode.Heartbeat:
SendFramework(pid, FrameworkOpcode.Heartbeat, frame.Payload); // 原样回显
break;
case FrameworkOpcode.MatchRequest:
var req = MatchRequestMsg.Decode(frame.Payload);
CreateRoomFor(pid, req.GameId, req.Version);
break;
}
}
private void CreateRoomFor(int pid, string gameId, int version)
{
var s = _sessions.Get(pid);
if (s == null) return; // 未建立会话,忽略
if (s.RoomId != null)
{
// 已在房间内(2b-1 暂无换房/离房流程):拒绝,避免覆盖 RoomId 造成状态损坏
SendFramework(pid, FrameworkOpcode.Error,
new ErrorMsg { Code = 2, Message = "already in room" }.Encode());
return;
}
string roomId = $"r{++_roomSeq}";
var players = new List<int> { pid };
var sink = new RoomOutputSink(roomId, players, _sessions);
var cfg = new RoomConfig { GameId = gameId, Version = version, Seed = (ulong)_roomSeq, PlayerCount = 1 };
var infos = new[] { new PlayerInfo { PlayerId = pid, Name = $"P{pid}", IsAI = false } };
Room room;
try { room = _host.CreateRoom(roomId, gameId, version, cfg, infos, sink); }
catch (Exception ex)
{
_logger.Error($"建房失败 {gameId}@{version}: {ex.Message}");
SendFramework(pid, FrameworkOpcode.Error,
new ErrorMsg { Code = 1, Message = "create room failed" }.Encode());
return;
}
s.RoomId = roomId;
_roomPlayers[roomId] = players;
var found = new MatchFoundMsg { RoomId = roomId, Version = version, SelfPlayerId = pid };
found.Players.Add(new PlayerInfo { PlayerId = pid, Name = $"P{pid}", IsAI = false });
SendFramework(pid, FrameworkOpcode.MatchFound, found.Encode());
if (!room.IsActive) OnRoomEnded(roomId); // 极端:Start 即结束
}
private void OnRoomEnded(string roomId)
{
if (!_roomPlayers.TryGetValue(roomId, out var players)) return;
_roomPlayers.Remove(roomId);
var msg = new RoomEndMsg { WinnerPlayerId = 0, ResultBlob = Array.Empty<byte>() };
byte[] payload = msg.Encode();
for (int i = 0; i < players.Count; i++)
{
int pid = players[i];
SendFramework(pid, FrameworkOpcode.RoomEnd, payload);
var s = _sessions.Get(pid);
if (s != null) s.RoomId = null;
}
}
private void SendFramework(int pid, FrameworkOpcode op, byte[] payload)
{
_sessions.GetConnection(pid)?.Send(
FrameCodec.Encode(new Frame(Channel.Framework, (ushort)op, payload)));
}
}
}
- Step 5: 运行,确认绿
Run: bash Server/build-test-games.sh && dotnet test Server/Server.sln --filter ServerLoopTests
Expected: PASS(5 个用例)。
Task 6: WebSocket 连接包装 Connection
Files:
- Create:
Server/Gateway/Connection.cs
收/发循环的 socket 行为由 Task 7 的真 Kestrel 集成测试覆盖(难以脱离 socket 单测)。
- Step 1: 写
Connection.cs
using System;
using System.IO;
using System.Net.WebSockets;
using System.Threading;
using System.Threading.Tasks;
using ChannelsNS = System.Threading.Channels;
using XWorld.Framework.Protocol;
namespace XWorld.Server.Gateway
{
// 一个 WebSocket 连接:收循环解码帧→Submit;发循环排空出站队列→SendAsync(每 socket 仅一个发循环,发送串行)
public sealed class Connection : IClientConnection
{
public int PlayerId { get; }
private readonly WebSocket _socket;
private readonly ServerLoop _loop;
private readonly ChannelsNS.Channel<byte[]> _outbound = ChannelsNS.Channel.CreateUnbounded<byte[]>();
public Connection(int playerId, WebSocket socket, ServerLoop loop)
{
PlayerId = playerId; _socket = socket; _loop = loop;
}
public void Send(byte[] frame) => _outbound.Writer.TryWrite(frame);
public async Task RunAsync(CancellationToken ct)
{
var sendTask = SendLoopAsync(ct);
try { await ReceiveLoopAsync(ct).ConfigureAwait(false); }
finally
{
_outbound.Writer.TryComplete();
await sendTask.ConfigureAwait(false);
}
}
private async Task ReceiveLoopAsync(CancellationToken ct)
{
var buf = new byte[8192];
using var ms = new MemoryStream();
try
{
while (_socket.State == WebSocketState.Open && !ct.IsCancellationRequested)
{
ms.SetLength(0);
WebSocketReceiveResult res;
do
{
res = await _socket.ReceiveAsync(new ArraySegment<byte>(buf), ct).ConfigureAwait(false);
if (res.MessageType == WebSocketMessageType.Close) return;
ms.Write(buf, 0, res.Count);
} while (!res.EndOfMessage);
Frame frame;
try { frame = FrameCodec.Decode(ms.ToArray()); }
catch (FormatException) { continue; } // 畸形帧:丢弃,不拖垮连接
_loop.Submit(new FrameCommand { PlayerId = PlayerId, Frame = frame });
}
}
catch (OperationCanceledException) { } // 主机关停/请求中止
catch (WebSocketException) { } // 对端突然断开(TCP 掉线)
}
private async Task SendLoopAsync(CancellationToken ct)
{
try
{
await foreach (var frame in _outbound.Reader.ReadAllAsync(ct).ConfigureAwait(false))
{
if (_socket.State != WebSocketState.Open) break;
await _socket.SendAsync(new ArraySegment<byte>(frame),
WebSocketMessageType.Binary, true, ct).ConfigureAwait(false);
}
}
catch (OperationCanceledException) { }
catch (WebSocketException) { } // 对端已断,停止发送
}
}
}
Task 7: Kestrel 宿主 GameServerHost + 端到端集成测试(真 WS)
Files:
-
Create:
Server/Gateway/GameServerHost.cs -
Create:
Server/Gateway.Tests/Support/WsTestClient.cs -
Test:
Server/Gateway.Tests/GatewayEndToEndTests.cs -
Step 1: 写
GameServerHost.cs
using System;
using System.Linq;
using System.Net;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Hosting.Server;
using Microsoft.AspNetCore.Hosting.Server.Features;
using System.Net.WebSockets;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using XWorld.Server.Host;
namespace XWorld.Server.Gateway
{
// 建 Kestrel + /ws 端点,把 socket 接入 ServerLoop。Start 返回实际监听地址(端口由系统分配)。
public sealed class GameServerHost
{
private WebApplication _app;
private ServerLoop _loop;
private CancellationTokenSource _cts;
private Task _loopTask;
public string WsBaseUrl { get; private set; } // 例如 ws://127.0.0.1:54321
public async Task StartAsync(string gamesRoot, int tickIntervalMs, long reconnectWindowTicks = 100)
{
_loop = new ServerLoop(gamesRoot, new ConsoleLogger("[loop]"), reconnectWindowTicks);
_cts = new CancellationTokenSource();
var builder = WebApplication.CreateBuilder();
builder.Logging.ClearProviders();
builder.WebHost.ConfigureKestrel(o => o.Listen(IPAddress.Loopback, 0)); // 临时端口
var app = builder.Build();
app.UseWebSockets();
app.Use(async (ctx, next) =>
{
if (ctx.Request.Path == "/ws" && ctx.WebSockets.IsWebSocketRequest)
{
if (!int.TryParse(ctx.Request.Query["pid"], out int pid))
{
ctx.Response.StatusCode = 400;
return;
}
using var ws = await ctx.WebSockets.AcceptWebSocketAsync();
var conn = new Connection(pid, ws, _loop);
_loop.Submit(new ConnectCommand { PlayerId = pid, Connection = conn });
try { await conn.RunAsync(ctx.RequestAborted); }
catch (OperationCanceledException) { } // 关停/中止:正常收束
finally
{
if (ws.State == WebSocketState.Open)
{
try { await ws.CloseAsync(WebSocketCloseStatus.NormalClosure, "server stopping", CancellationToken.None); }
catch { /* 关闭握手尽力而为 */ }
}
_loop.Submit(new DisconnectCommand { PlayerId = pid, Connection = conn });
}
}
else
{
await next();
}
});
await app.StartAsync();
_app = app;
var addresses = app.Services.GetRequiredService<IServer>()
.Features.Get<IServerAddressesFeature>().Addresses;
string http = addresses.FirstOrDefault() // http://127.0.0.1:port
?? throw new InvalidOperationException("Kestrel 已启动但未注册任何监听地址");
WsBaseUrl = http.Replace("http://", "ws://");
_loopTask = _loop.RunAsync(tickIntervalMs, _cts.Token);
}
public async Task StopAsync()
{
_cts.Cancel();
try { await _loopTask; } catch { /* 忽略取消 */ }
await _app.StopAsync();
await _app.DisposeAsync();
}
}
}
- Step 2: 写
Support/WsTestClient.cs
using System;
using System.IO;
using System.Net.WebSockets;
using System.Threading;
using System.Threading.Tasks;
using XWorld.Framework.Protocol;
namespace XWorld.Server.Gateway.Tests
{
// 集成测试用 WS 客户端:连接、发帧、带超时收一帧并解码
internal sealed class WsTestClient : IDisposable
{
private readonly ClientWebSocket _ws = new ClientWebSocket();
public async Task ConnectAsync(string wsBaseUrl, int pid)
{
await _ws.ConnectAsync(new Uri($"{wsBaseUrl}/ws?pid={pid}"), CancellationToken.None);
}
public async Task SendFrameAsync(Frame frame)
{
byte[] bytes = FrameCodec.Encode(frame);
await _ws.SendAsync(new ArraySegment<byte>(bytes), WebSocketMessageType.Binary, true, CancellationToken.None);
}
// 在 timeout 内收一条 WS 消息并解码为 Frame;超时抛 TimeoutException
public async Task<Frame> ReceiveFrameAsync(int timeoutMs = 5000)
{
using var cts = new CancellationTokenSource(timeoutMs);
var buf = new byte[8192];
using var ms = new MemoryStream();
WebSocketReceiveResult res;
do
{
res = await _ws.ReceiveAsync(new ArraySegment<byte>(buf), cts.Token);
if (res.MessageType == WebSocketMessageType.Close)
throw new InvalidOperationException("server closed");
ms.Write(buf, 0, res.Count);
} while (!res.EndOfMessage);
return FrameCodec.Decode(ms.ToArray());
}
public async Task CloseAsync()
{
try { await _ws.CloseAsync(WebSocketCloseStatus.NormalClosure, "bye", CancellationToken.None); }
catch { /* 忽略 */ }
}
public void Dispose() => _ws.Dispose();
}
}
- Step 3: 写集成测试
GatewayEndToEndTests.cs
using System.Collections.Generic;
using System.Threading.Tasks;
using Xunit;
using XWorld.Framework.Protocol;
using XWorld.Server.Gateway;
namespace XWorld.Server.Gateway.Tests
{
public class GatewayEndToEndTests
{
private static Frame JoinHello()
=> new Frame(Channel.Framework, (ushort)FrameworkOpcode.MatchRequest,
new MatchRequestMsg { GameId = "hello", Version = 1 }.Encode());
[Fact]
public async Task Connect_Join_ReceivesMatchFound_Snapshots_RoomEnd()
{
var host = new GameServerHost();
await host.StartAsync(TestGames.Root, tickIntervalMs: 20);
try
{
using var client = new WsTestClient();
await client.ConnectAsync(host.WsBaseUrl, pid: 1);
await client.SendFrameAsync(JoinHello());
// 收帧直到 RoomEnd
bool matchFound = false, roomEnd = false;
int snapshots = 0;
for (int i = 0; i < 20 && !roomEnd; i++)
{
var f = await client.ReceiveFrameAsync(5000);
if (f.Channel == Channel.Framework && f.Opcode == (ushort)FrameworkOpcode.MatchFound) matchFound = true;
else if (f.Channel == Channel.Game) snapshots++;
else if (f.Channel == Channel.Framework && f.Opcode == (ushort)FrameworkOpcode.RoomEnd) roomEnd = true;
}
Assert.True(matchFound, "未收到 MatchFound");
Assert.True(snapshots >= 3, $"快照数应≥3,实际 {snapshots}");
Assert.True(roomEnd, "未收到 RoomEnd");
await client.CloseAsync();
}
finally { await host.StopAsync(); }
}
[Fact]
public async Task Heartbeat_IsEchoedBack()
{
var host = new GameServerHost();
await host.StartAsync(TestGames.Root, tickIntervalMs: 20);
try
{
using var client = new WsTestClient();
await client.ConnectAsync(host.WsBaseUrl, pid: 7);
await client.SendFrameAsync(new Frame(Channel.Framework, (ushort)FrameworkOpcode.Heartbeat,
new HeartbeatMsg { ClientTimeMs = 999L }.Encode()));
Frame? echo = null; // Frame 是 readonly struct,用可空值类型
for (int i = 0; i < 10; i++)
{
var f = await client.ReceiveFrameAsync(5000);
if (f.Channel == Channel.Framework && f.Opcode == (ushort)FrameworkOpcode.Heartbeat) { echo = f; break; }
}
Assert.True(echo.HasValue);
Assert.Equal(999L, HeartbeatMsg.Decode(echo.Value.Payload).ClientTimeMs);
await client.CloseAsync();
}
finally { await host.StopAsync(); }
}
[Fact]
public async Task Reconnect_ContinuesReceivingFromSameRoom()
{
var host = new GameServerHost();
// 大 tick 间隔给重连留足时间(Hello 3 tick = 3s)
await host.StartAsync(TestGames.Root, tickIntervalMs: 1000, reconnectWindowTicks: 100);
try
{
var client1 = new WsTestClient();
await client1.ConnectAsync(host.WsBaseUrl, pid: 42);
await client1.SendFrameAsync(JoinHello());
// 收到 MatchFound + 第一个快照(首个 tick 在加入后约 ≤1s 触发)
bool gotSnapshot = false;
for (int i = 0; i < 5 && !gotSnapshot; i++)
{
var f = await client1.ReceiveFrameAsync(5000);
if (f.Channel == Channel.Game) gotSnapshot = true;
}
Assert.True(gotSnapshot, "重连前应先收到一个快照");
// 断开
await client1.CloseAsync();
client1.Dispose();
// 用同 pid 重连
using var client2 = new WsTestClient();
await client2.ConnectAsync(host.WsBaseUrl, pid: 42);
// 房间仍在跑,下一个快照应发到 client2
bool gotAfterReconnect = false;
for (int i = 0; i < 5 && !gotAfterReconnect; i++)
{
var f = await client2.ReceiveFrameAsync(5000);
if (f.Channel == Channel.Game) gotAfterReconnect = true;
else if (f.Channel == Channel.Framework && f.Opcode == (ushort)FrameworkOpcode.RoomEnd) { gotAfterReconnect = true; }
}
Assert.True(gotAfterReconnect, "重连后应继续收到房间输出(快照或 RoomEnd)");
await client2.CloseAsync();
}
finally { await host.StopAsync(); }
}
}
}
- Step 4: 运行集成测试,确认绿
Run: bash Server/build-test-games.sh && dotnet test Server/Server.sln --filter GatewayEndToEndTests
Expected: 3 个集成测试 PASS(真 Kestrel + ClientWebSocket,端到端连/入房/收快照/结束、心跳回显、断线重连续收)。
若
Connect_Join_...偶发超时:确认 tickIntervalMs 较小(20ms),并发循环RunAsync在首个Task.Delay前已DrainAndTick一次。若重连测试偶发:增大tickIntervalMs(如 1500)给客户端重连更多余量。真实端口/真实 WS 帧不应有功能性失败。
Task 8: 收尾——全量验证
- Step 1: 生成夹具并跑全量
Run:
bash Server/build-test-games.sh
dotnet test Server/Server.sln
Expected: 全绿。= 子项目 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.sln0 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 间隔由宿主参数给定)。