Initial commit: Client Doc docs Server Tools

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
ud18010
2026-07-10 10:24:29 +08:00
co-authored by Cursor
commit 7e35d8da31
3374 changed files with 680813 additions and 0 deletions
@@ -0,0 +1,632 @@
# 用 C# 重写 RockPaperScissors + 匹配 + AI 兜底(§9 第 5 步,试金石)实现计划
> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development / executing-plans. Steps use `- [ ]`.
>
> **服务端全部可 TDD**RPS.Core / RPS.Server / Matchmaker / AI 兜底 / 真 Kestrel WS 端到端,`dotnet test`)。**RPS.Client 是 Unity**(标注「[Unity 人工核对]」)。
**Goal:** 把石头剪刀布从 Lua 重写为 C#:`RPS.Core`(纯规则 `IGameLogic`+ `RPS.Server``IGameServerRoom`,含 AI 出招)+ 服务端**匹配**(按 (gameId,version) 分桶、凑齐 2 人建房、15s 超时 AI 兜底)+ `RPS.Client``IGameClient` 表现层,Unity)。验证「写一个新小游戏 = 框架零改动 + 一份 Core 两端共用」,端到端跑通匹配→对局→结算(含 AI 兜底)。
**Architecture:** `RPS.Core` 用子项目 1 的 `IGameLogic<TState,TInput,TEvent>` + `DeterministicRandom`,纯函数式推进,两端共用、编解码一份。`RPS.Server` 实现 `IGameServerRoom`,经 2a 的 ALC 像 Hello 一样动态加载;AI 座位由 `ctx.Random` 等概率出招(不读对手本回合选择,沿用原 Lua AI 规则)。**匹配器**加在 2b-1 的 `ServerLoop` 前:MatchRequest 入桶,凑齐 `playerCount` 或 15s(N tick)超时→AI 填位→建多人房。AI 玩家是一个 `Send` 为 no-op 的 `IClientConnection``RPS.Client` 经第 3 步 `MiniGameHost` 加载,渲染回合 UI。
**Tech Stack:** C# / RPS.Core+RPS.Server netstandard2.1(两端,ALC 加载)· 子项目 1 契约/协议 + 2a RoomHost/ALC + 2b-1 Gateway/ServerLoop · xUnit + 真 Kestrel/ClientWebSocket e2e · RPS.Client UnityHybridCLR 热更,第 3 步加载)。
---
## 背景与原 RPS 规则(沿用现有 Lua 玩法)
源规则见 `Client/Assets/RockPaperScissors/script/Server/main.lua`
- **2 人对战**,总时长 60s;每回合:**选择 5s** → **展示 5s** → 下一回合,直到 60s 结束。
- 每回合双方各出 rock/paper/scissors`rock>scissors, paper>rock, scissors>paper`;胜者本回合 +1 分;未出招者超时自动**等概率随机**补出。
- 结算:总分高者胜;平分则平局。
- **AI 兜底**:匹配超过 15s 未凑齐真人 → 用 AI 补位(`IsAI=true`),AI 等概率随机出招、不读真人本回合选择。
- 已完成:子项目 1(契约/协议)、2aRoomHost/ALC/Hello 加载范式)、2b-1Gateway/ServerLoop/Session/路由)。RPS.Server 用与 Hello 相同的 ALC + games 目录约定加载。
- **无 git**:跳过 git 步骤。工作目录 `D:/UD/AI/AIC#Project``dotnet` SDK 10。集成测试前 `bash Server/build-test-games.sh`(将扩展以构建 RPS 夹具)。
## 文件结构
RPS 源码(夹具游戏,netstandard2.1,不入 Server.sln,由脚本构建到 TestGames):`Server/games-src/RPS/`
| 文件 | 职责 |
|---|---|
| `RPS.Core/RPS.Core.csproj` | netstandard2.1,引用 Framework.SharedPrivate=false |
| `RPS.Core/RpsTypes.cs` | `Choice`/`RpsPhase`/`RpsState`/`RpsInput`/`RpsEvent` |
| `RPS.Core/RpsLogic.cs` | `IGameLogic<RpsState,RpsInput,RpsEvent>`:选择/计时/判定/结算 + 编解码 |
| `RPS.Server/RPS.Server.csproj` | netstandard2.1,引用 Framework.Shared(Private=false)+RPS.Core |
| `RPS.Server/RpsServerRoom.cs` | `IGameServerRoom`:座位映射、AI 出招、广播快照、结算 |
服务端匹配(加进 Gateway):`Server/Gateway/`
| 文件 | 职责 |
|---|---|
| `Matchmaker.cs` | 按 (gameId,version) 分桶;凑齐 N 或超时 AI 兜底;产出座位名单 |
| `ServerLoop.cs`(改) | MatchRequest → 入桶;`DrainAndTick` 驱动匹配;多人/AI 建房 |
| `RoomHost`(2a,复用) | 多人房间已支持 |
客户端(Unity 人工):`Client/Assets/RockPaperScissors/csharp/`(新)
| 文件 | 职责 |
|---|---|
| `RpsGameClient.cs` | `IGameClient`:加载 UI prefab、渲染回合/计时/比分、出招按钮→ctx.Send |
测试:`Server/Gateway.Tests/` + `Server/Server.Host.Tests/`(RPS.Core 抽测可放任一测试工程或新建)。
---
## Task 1: RPS.Core 类型与规则(TDD,纯逻辑)
**Files:** Create `Server/games-src/RPS/RPS.Core/{RPS.Core.csproj,RpsTypes.cs,RpsLogic.cs}`Test:在 `Server/Server.Host.Tests/` 新增 `RpsLogicTests.cs`(该工程已引用 Framework.Shared;为抽测 RPS.Core,临时给 Server.Host.Tests 加对 RPS.Core 的 ProjectReference —— 仅测试用,RPS.Core 仍不入 sln 主依赖)。
> 注:为单测 RPS.Core,给 `Server.Host.Tests.csproj` 加 `<ProjectReference Include="..\games-src\RPS\RPS.Core\RPS.Core.csproj" />`。这只让**测试**直接引用 Core 做单元测试;运行时服务端仍通过 ALC 动态加载(与之并不冲突)。
- [ ] **Step 1: 写 `RPS.Core.csproj`**
```xml
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.1</TargetFramework>
<LangVersion>9.0</LangVersion>
<Nullable>disable</Nullable>
<AssemblyName>RPS.Core</AssemblyName>
<RootNamespace>RPS.Core</RootNamespace>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\Framework.Shared\Framework.Shared.csproj">
<Private>false</Private>
</ProjectReference>
</ItemGroup>
</Project>
```
- [ ] **Step 2: 写 `RpsTypes.cs`**
```csharp
using System;
namespace RPS.Core
{
public enum Choice : byte { None = 0, Rock = 1, Paper = 2, Scissors = 3 }
public enum RpsPhase : byte { Choosing = 0, Revealing = 1, Finished = 2 }
public sealed class RpsState
{
public int Round;
public RpsPhase Phase;
public float PhaseElapsed; // 当前阶段已用秒
public float TotalElapsed; // 全局已用秒
public Choice[] Choices = new Choice[2]; // 本回合两座位的选择
public int[] Scores = new int[2];
public bool[] IsAi = new bool[2];
public int Winner = -1; // 结束时:0/1=胜者座位,-1=未结束,2=平局
}
public enum RpsInputKind : byte { Tick = 0, Choose = 1 }
public struct RpsInput
{
public RpsInputKind Kind;
public int Seat; // Choose 用
public Choice Choice;// Choose 用
public float Dt; // Tick 用
public static RpsInput Tick(float dt) => new RpsInput { Kind = RpsInputKind.Tick, Dt = dt };
public static RpsInput Choose(int seat, Choice c) => new RpsInput { Kind = RpsInputKind.Choose, Seat = seat, Choice = c };
}
public enum RpsEventKind : byte { RoundResolved = 0, GameFinished = 1 }
public struct RpsEvent
{
public RpsEventKind Kind;
public int Round;
public int RoundWinner; // RoundResolved0/1 胜者座位,-1 平局
public int GameWinner; // GameFinished0/1 或 2 平局
}
}
```
- [ ] **Step 3: 写失败测试 `RpsLogicTests.cs`**(放 `Server/Server.Host.Tests/`
```csharp
using System.Collections.Generic;
using Xunit;
using XWorld.Framework;
using RPS.Core;
namespace XWorld.Server.Host.Tests
{
public class RpsLogicTests
{
private const float Choose = 5f, Reveal = 5f, Total = 60f;
private static RpsState New() => new RpsLogic().CreateInitial(
new RoomConfig { GameId = "rps", Version = 1, Seed = 1, PlayerCount = 2 }, new DeterministicRandom(1));
[Fact]
public void Initial_IsChoosingRound1()
{
var s = New();
Assert.Equal(1, s.Round);
Assert.Equal(RpsPhase.Choosing, s.Phase);
Assert.Equal(Choice.None, s.Choices[0]);
}
[Fact]
public void Choose_LocksSeatChoice_DuringChoosing()
{
var logic = new RpsLogic();
var s = New();
s = logic.Step(s, RpsInput.Choose(0, Choice.Rock), null).State;
Assert.Equal(Choice.Rock, s.Choices[0]);
// 二次出招不覆盖
s = logic.Step(s, RpsInput.Choose(0, Choice.Paper), null).State;
Assert.Equal(Choice.Rock, s.Choices[0]);
}
[Fact]
public void ChoosingTimeout_AutoRandomsMissing_AndResolves_RockBeatsScissors()
{
var logic = new RpsLogic();
var s = New();
s = logic.Step(s, RpsInput.Choose(0, Choice.Rock), null).State;
s = logic.Step(s, RpsInput.Choose(1, Choice.Scissors), null).State;
// 推进超过 5s 选择阶段 → 结算本回合
var r = logic.Step(s, RpsInput.Tick(Choose), new DeterministicRandom(1));
s = r.State;
Assert.Equal(RpsPhase.Revealing, s.Phase);
Assert.Equal(1, s.Scores[0]); // rock 胜 scissors
Assert.Equal(0, s.Scores[1]);
Assert.Contains(r.Events, e => e.Kind == RpsEventKind.RoundResolved && e.RoundWinner == 0);
}
[Fact]
public void Tie_NoScore()
{
var logic = new RpsLogic();
var s = New();
s = logic.Step(s, RpsInput.Choose(0, Choice.Rock), null).State;
s = logic.Step(s, RpsInput.Choose(1, Choice.Rock), null).State;
s = logic.Step(s, RpsInput.Tick(Choose), new DeterministicRandom(1)).State;
Assert.Equal(0, s.Scores[0]);
Assert.Equal(0, s.Scores[1]);
}
[Fact]
public void RevealTimeout_StartsNextRound()
{
var logic = new RpsLogic();
var s = New();
s = logic.Step(s, RpsInput.Choose(0, Choice.Rock), null).State;
s = logic.Step(s, RpsInput.Choose(1, Choice.Scissors), null).State;
s = logic.Step(s, RpsInput.Tick(Choose), new DeterministicRandom(1)).State; // → Revealing
s = logic.Step(s, RpsInput.Tick(Reveal), new DeterministicRandom(1)).State; // → next round
Assert.Equal(2, s.Round);
Assert.Equal(RpsPhase.Choosing, s.Phase);
Assert.Equal(Choice.None, s.Choices[0]); // 新回合清空
}
[Fact]
public void TotalTimeout_Finishes_WithWinnerByScore()
{
var logic = new RpsLogic();
var s = New();
// 座位0 赢一回合,然后把总时间推到 60s
s = logic.Step(s, RpsInput.Choose(0, Choice.Rock), null).State;
s = logic.Step(s, RpsInput.Choose(1, Choice.Scissors), null).State;
s = logic.Step(s, RpsInput.Tick(Choose), new DeterministicRandom(1)).State;
var r = logic.Step(s, RpsInput.Tick(Total), new DeterministicRandom(1)); // 越过总时长
s = r.State;
Assert.Equal(RpsPhase.Finished, s.Phase);
Assert.Equal(0, s.Winner); // 座位0 分高
Assert.Contains(r.Events, e => e.Kind == RpsEventKind.GameFinished && e.GameWinner == 0);
}
[Fact]
public void Encode_Decode_RoundTrips()
{
var logic = new RpsLogic();
var s = New();
s.Round = 3; s.Phase = RpsPhase.Revealing; s.Scores[0] = 2; s.Scores[1] = 1;
s.Choices[0] = Choice.Paper; s.Choices[1] = Choice.Rock;
s.TotalElapsed = 12.5f; s.PhaseElapsed = 2.5f;
s.IsAi[0] = false; s.IsAi[1] = true; s.Winner = 1;
var back = logic.Decode(logic.Encode(s));
Assert.Equal(3, back.Round);
Assert.Equal(RpsPhase.Revealing, back.Phase);
Assert.Equal(2, back.Scores[0]);
Assert.Equal(Choice.Paper, back.Choices[0]);
Assert.Equal(Choice.Rock, back.Choices[1]);
Assert.Equal(12.5f, back.TotalElapsed, 3);
Assert.Equal(2.5f, back.PhaseElapsed, 3);
Assert.False(back.IsAi[0]);
Assert.True(back.IsAi[1]);
Assert.Equal(1, back.Winner);
}
[Fact]
public void TotalTimeout_DuringRevealing_Finishes()
{
var logic = new RpsLogic();
var s = New();
s = logic.Step(s, RpsInput.Choose(0, Choice.Rock), null).State;
s = logic.Step(s, RpsInput.Choose(1, Choice.Scissors), null).State;
s = logic.Step(s, RpsInput.Tick(5f), new DeterministicRandom(1)).State; // → Revealing, 座位0 得分
Assert.Equal(RpsPhase.Revealing, s.Phase);
s.TotalElapsed = 58f;
var r = logic.Step(s, RpsInput.Tick(2f), new DeterministicRandom(1)); // total→60 → Finish
s = r.State;
Assert.Equal(RpsPhase.Finished, s.Phase);
Assert.Equal(0, s.Winner);
Assert.Contains(r.Events, e => e.Kind == RpsEventKind.GameFinished);
}
[Fact]
public void SameSeed_AutoRandom_IsDeterministic()
{
var logic = new RpsLogic();
RpsState Run(ulong seed)
{
var s = New();
// 双方都不出招,靠超时自动随机
return logic.Step(s, RpsInput.Tick(Choose), new DeterministicRandom(seed)).State;
}
var a = Run(7); var b = Run(7);
Assert.Equal(a.Choices[0], b.Choices[0]);
Assert.Equal(a.Choices[1], b.Choices[1]);
}
}
}
```
- [ ] **Step 4:** Run(先给 Server.Host.Tests 加 RPS.Core 的 ProjectReference`bash Server/build-test-games.sh && dotnet test Server/Server.sln --filter RpsLogicTests` → REDRpsLogic 不存在)。
- [ ] **Step 5: 写 `RpsLogic.cs`**
```csharp
using System;
using System.Collections.Generic;
using XWorld.Framework;
using XWorld.Framework.Protocol;
namespace RPS.Core
{
public sealed class RpsLogic : IGameLogic<RpsState, RpsInput, RpsEvent>
{
public const float ChooseSeconds = 5f;
public const float RevealSeconds = 5f;
public const float TotalSeconds = 60f;
public RpsState CreateInitial(RoomConfig config, IRandom random)
{
var s = new RpsState { Round = 1, Phase = RpsPhase.Choosing };
return s;
}
public StepResult<RpsState, RpsEvent> Step(RpsState state, RpsInput input, IRandom random)
{
var events = new List<RpsEvent>();
if (state.Phase == RpsPhase.Finished) return new StepResult<RpsState, RpsEvent>(state, events);
if (input.Kind == RpsInputKind.Choose)
{
if (state.Phase == RpsPhase.Choosing && state.Choices[input.Seat] == Choice.None
&& input.Choice != Choice.None)
state.Choices[input.Seat] = input.Choice;
return new StepResult<RpsState, RpsEvent>(state, events);
}
// Tick
state.PhaseElapsed += input.Dt;
state.TotalElapsed += input.Dt;
if (state.Phase == RpsPhase.Choosing && state.PhaseElapsed >= ChooseSeconds)
{
ResolveRound(state, random, events);
state.Phase = RpsPhase.Revealing;
state.PhaseElapsed = 0f;
}
else if (state.Phase == RpsPhase.Revealing && state.PhaseElapsed >= RevealSeconds)
{
state.Round++;
state.Phase = RpsPhase.Choosing;
state.PhaseElapsed = 0f;
state.Choices[0] = Choice.None; state.Choices[1] = Choice.None;
}
if (state.Phase != RpsPhase.Finished && state.TotalElapsed >= TotalSeconds)
Finish(state, events);
return new StepResult<RpsState, RpsEvent>(state, events);
}
private static readonly Choice[] All = { Choice.Rock, Choice.Paper, Choice.Scissors };
private void ResolveRound(RpsState s, IRandom rng, List<RpsEvent> events)
{
for (int i = 0; i < 2; i++)
if (s.Choices[i] == Choice.None)
s.Choices[i] = All[rng.Next(3)]; // 等概率自动随机
int winner = WinnerOf(s.Choices[0], s.Choices[1]); // -1 平局,0/1 胜者座位
if (winner >= 0) s.Scores[winner]++;
events.Add(new RpsEvent { Kind = RpsEventKind.RoundResolved, Round = s.Round, RoundWinner = winner });
}
// 0 胜返回 0;1 胜返回 1;平返回 -1
private static int WinnerOf(Choice a, Choice b)
{
if (a == b) return -1;
bool aWins = (a == Choice.Rock && b == Choice.Scissors)
|| (a == Choice.Paper && b == Choice.Rock)
|| (a == Choice.Scissors && b == Choice.Paper);
return aWins ? 0 : 1;
}
private void Finish(RpsState s, List<RpsEvent> events)
{
s.Phase = RpsPhase.Finished;
s.Winner = s.Scores[0] == s.Scores[1] ? 2 : (s.Scores[0] > s.Scores[1] ? 0 : 1);
events.Add(new RpsEvent { Kind = RpsEventKind.GameFinished, GameWinner = s.Winner });
}
public byte[] Encode(RpsState s)
{
var w = new PacketWriter();
w.WriteVarInt(s.Round);
w.WriteByte((byte)s.Phase);
w.WriteSingle(s.PhaseElapsed);
w.WriteSingle(s.TotalElapsed);
w.WriteByte((byte)s.Choices[0]); w.WriteByte((byte)s.Choices[1]);
w.WriteVarInt(s.Scores[0]); w.WriteVarInt(s.Scores[1]);
w.WriteBool(s.IsAi[0]); w.WriteBool(s.IsAi[1]);
w.WriteVarInt(s.Winner);
return w.ToArray();
}
public RpsState Decode(byte[] data)
{
var r = new PacketReader(data);
var s = new RpsState
{
Round = r.ReadVarInt(),
Phase = (RpsPhase)r.ReadByte(),
PhaseElapsed = r.ReadSingle(),
TotalElapsed = r.ReadSingle(),
};
s.Choices[0] = (Choice)r.ReadByte(); s.Choices[1] = (Choice)r.ReadByte();
s.Scores[0] = r.ReadVarInt(); s.Scores[1] = r.ReadVarInt();
s.IsAi[0] = r.ReadBool(); s.IsAi[1] = r.ReadBool();
s.Winner = r.ReadVarInt();
return s;
}
}
}
```
- [ ] **Step 6:** Run filter → GREEN8 用例)。
---
## Task 2: RPS.Server 房间逻辑(TDD via 直接驱动)
**Files:** Create `Server/games-src/RPS/RPS.Server/{RPS.Server.csproj,RpsServerRoom.cs}`Test `Server/Server.Host.Tests/RpsServerRoomTests.cs`(直接 new RpsServerRoom 驱动,用 2a 的 RoomCtx/能力 + 假 IRoomOutput)。
> 座位约定:`OnRoomStart` 收到 `IReadOnlyList<PlayerInfo>`,按顺序映射 playerId→座位 0/1,记录 `IsAI`。`OnMessage`:解出 Choice`Core.Step(Choose)`。`OnTick`Choosing 阶段给 AI 座位 `ctx.Random` 出招;`Core.Step(Tick,dt)`;广播快照(opcode=1payload=Core.Encode);Finished→`ctx.EndRoom`。
- [ ] **Step 1: 写 `RPS.Server.csproj`**netstandard2.1,引用 Framework.Shared `Private=false` + RPS.Core)。
- [ ] **Step 2: 写失败测试 `RpsServerRoomTests.cs`**(要点):
- 2 个真人座位:OnRoomStart→OnMessage 各出招→多次 OnTick(5f) 推进→广播快照计数>0、座位分按规则更新;推进到 60s→房间结束(ctx EndRoom 触发)。
- 1 真人 + 1 AI 座位:OnTick 时 AI 自动出招,对局能推进并结算。
- (断言通过解码广播的快照 payloadRpsLogic.Decode)读取 Round/Scores/Phase。)
- [ ] **Step 3: 写 `RpsServerRoom.cs`**
```csharp
using System.Collections.Generic;
using XWorld.Framework;
using XWorld.Framework.Protocol;
using RPS.Core;
namespace RPS.Server
{
public sealed class RpsServerRoom : IGameServerRoom
{
public const ushort SnapshotOpcode = 1;
public const ushort ChoiceOpcode = 1; // 客户端出招消息 opcode
private readonly RpsLogic _logic = new RpsLogic();
private IRoomCtx _ctx;
private RpsState _state;
private readonly Dictionary<int, int> _seatOf = new Dictionary<int, int>(); // playerId->seat
private bool _ended;
public void OnRoomStart(IReadOnlyList<PlayerInfo> players, IRoomCtx ctx)
{
_ctx = ctx;
_state = _logic.CreateInitial(new RoomConfig { GameId = "rps", Version = 1, Seed = 1, PlayerCount = players.Count }, ctx.Random);
for (int i = 0; i < players.Count && i < 2; i++)
{
_seatOf[players[i].PlayerId] = i;
_state.IsAi[i] = players[i].IsAI;
}
Broadcast();
}
public void OnMessage(int playerId, NetMessage message)
{
if (message.Opcode != ChoiceOpcode) return;
if (!_seatOf.TryGetValue(playerId, out int seat)) return;
var r = new PacketReader(message.Payload);
var choice = (Choice)r.ReadByte();
_state = _logic.Step(_state, RpsInput.Choose(seat, choice), _ctx.Random).State;
}
public void OnTick(float dt)
{
if (_ended) return;
// Choosing 阶段:AI 座位等概率出招(不读对手),仅在尚未出招时
if (_state.Phase == RpsPhase.Choosing)
for (int seat = 0; seat < 2; seat++)
if (_state.IsAi[seat] && _state.Choices[seat] == Choice.None)
{
var c = new[] { Choice.Rock, Choice.Paper, Choice.Scissors }[_ctx.Random.Next(3)];
_state = _logic.Step(_state, RpsInput.Choose(seat, c), _ctx.Random).State;
}
var res = _logic.Step(_state, RpsInput.Tick(dt), _ctx.Random);
_state = res.State;
Broadcast();
if (_state.Phase == RpsPhase.Finished)
{
_ended = true;
_ctx.Logger.Info($"rps finished, winner seat={_state.Winner}, scores {_state.Scores[0]}:{_state.Scores[1]}");
_ctx.EndRoom();
}
}
public void OnRoomEnd() => _ctx.Logger.Info("rps room end");
private void Broadcast() => _ctx.Broadcast(new NetMessage(SnapshotOpcode, _logic.Encode(_state)));
}
}
```
- [ ] **Step 4:** GREEN。
---
## Task 3: RPS 夹具构建(扩展 build-test-games.sh
**Files:** Modify `Server/build-test-games.sh`(追加构建 RPS 到 `TestGames/rps/1/`)。
- [ ] **Step 1:** 在脚本里追加一个 `build_rps` 函数:`dotnet build RPS.Server.csproj -c Release --no-incremental` → 拷 `RPS.Core.dll`/`RPS.Server.dll``Server/Server.Host.Tests/TestGames/rps/1/`,写 `game.json`gameId=rps,version=1,serverAssembly=RPS.Server.dll,serverEntryType=RPS.Server.RpsServerRoom,playerCount=2,tickRateHz=10),断言不带 Framework.Shared.dll。
- [ ] **Step 2:** Run `bash Server/build-test-games.sh` → 确认 `TestGames/rps/1/` 三件套齐全、无 Framework.Shared.dll。
---
## Task 4: 通过 ALC 加载 RPS(集成 TDD
**Files:** Test `Server/Server.Host.Tests/RpsModuleLoadTests.cs`
- [ ] **Step 1:** 写测试:用 2a 的 `GameModuleLoader().Load(TestGames.Root, "rps", 1)` 加载,`CreateRoom()` 得到 `IGameServerRoom`OnRoomStart2 座位,其一 IsAI+ 多次 OnTick→广播快照可解码、能推进到结算。证明「框架零改动加载新游戏」(与 Hello 同范式)。
- [ ] **Step 2:** GREEN。
---
## Task 5: 匹配器 MatchmakerTDD
**Files:** Create `Server/Gateway/Matchmaker.cs`Test `Server/Gateway.Tests/MatchmakerTests.cs`
> 纯逻辑:按 (gameId,version) 分桶,记录等待玩家与入桶 tick`Poll(currentTick)` 返回已成形的对局(凑齐 N,或超时用 AI 补足 N)。不碰网络/房间。
- [ ] **Step 1: 失败测试**(要点):
- `Enqueue(pid, gameId, ver, playerCount, tick)`;两次 Enqueue 同桶且 playerCount=2 → `Poll` 返回一个含两真人的 match。
- 单人入桶,`Poll(tick+timeout+1)` → 返回含 1 真人 + 1 AIIsAI=truepid 为负)的 match。
- 未满且未超时 → `Poll` 返回空。
- [ ] **Step 2: 实现**
```csharp
using System.Collections.Generic;
namespace XWorld.Server.Gateway
{
public sealed class Matchmaker
{
public sealed class Seat { public int PlayerId; public bool IsAi; }
public sealed class Match { public string GameId; public int Version; public List<Seat> Seats = new List<Seat>(); }
private sealed class Waiter { public int Pid; public long EnqueuedTick; }
private sealed class Bucket { public int PlayerCount; public List<Waiter> Waiters = new List<Waiter>(); }
private readonly long _timeoutTicks;
private int _aiSeq;
private readonly Dictionary<string, Bucket> _buckets = new Dictionary<string, Bucket>();
public Matchmaker(long timeoutTicks) { _timeoutTicks = timeoutTicks; }
private static string Key(string g, int v) => $"{g}@{v}";
public void Enqueue(int pid, string gameId, int version, int playerCount, long tick)
{
string k = Key(gameId, version);
if (!_buckets.TryGetValue(k, out var b)) { b = new Bucket { PlayerCount = playerCount }; _buckets[k] = b; }
b.Waiters.Add(new Waiter { Pid = pid, EnqueuedTick = tick });
}
// 返回本次可成形的对局(凑齐或超时 AI 补足)
public IReadOnlyList<Match> Poll(long currentTick)
{
List<Match> formed = null;
foreach (var kv in _buckets)
{
var b = kv.Value;
string[] gv = kv.Key.Split('@');
while (b.Waiters.Count > 0 &&
(b.Waiters.Count >= b.PlayerCount ||
currentTick - b.Waiters[0].EnqueuedTick > _timeoutTicks))
{
var m = new Match { GameId = gv[0], Version = int.Parse(gv[1]) };
int take = b.Waiters.Count >= b.PlayerCount ? b.PlayerCount : b.Waiters.Count;
for (int i = 0; i < take; i++)
m.Seats.Add(new Seat { PlayerId = b.Waiters[i].PlayerId, IsAi = false });
b.Waiters.RemoveRange(0, take);
while (m.Seats.Count < b.PlayerCount)
m.Seats.Add(new Seat { PlayerId = -(++_aiSeq), IsAi = true }); // AI 用负 pid
(formed ??= new List<Match>()).Add(m);
}
}
return formed ?? (IReadOnlyList<Match>)System.Array.Empty<Match>();
}
}
}
```
- [ ] **Step 3:** GREEN。
---
## Task 6: ServerLoop 接入匹配 + 多人/AI 建房(TDD)
**Files:** Modify `Server/Gateway/ServerLoop.cs`Test `Server/Gateway.Tests/ServerLoopMatchmakingTests.cs`
> 改动:`HandleFramework`(MatchRequest) 不再立刻 `CreateRoomFor`,而是 `_matchmaker.Enqueue(pid, gameId, version, playerCount, _tick)`playerCount 暂从一个 gameId→count 映射/或固定按 manifest;本步骤可用「已知 rps=2、hello=1」的简单解析或读 game.json)。`DrainAndTick` 末尾 `_matchmaker.Poll(_tick)`,对每个 Match 调新的 `CreateRoomForSeats(match)`:真人座位绑 session.RoomIdAI 座位无 session;用 `RoomOutputSink(roomId, realPids, _sessions)`(只对真人广播,AI 由 GetConnection=null 跳过);reply MatchFound 给真人座位。playerCount 来源:从 `games/{id}/{ver}/game.json` 读 `playerCount`(宿主可加一个轻量读取,或 Matchmaker 入桶时由调用方传入——本步骤 ServerLoop 维护 `gameId->playerCount` 小映射并允许从 manifest 懒加载)。
- [ ] **Step 1: 失败测试**(用 RPS 夹具 + FakeConnection):
- 两个 FakeConnection 各发 MatchRequest(rps,1) → 若干 DrainAndTick → 两者都收到 MatchFound(同一 roomId、含 2 名玩家)→ 各自发出招 → 推进至结算 → 两者都收到 RoomEnd。
- 单个 FakeConnection 发 MatchRequest(rps,1) → DrainAndTick 越过超时 → 收到 MatchFound(含 1 真人 + 1 AI 标记)→ AI 自动出招 → 推进至结算 → 收到 RoomEnd。
- [ ] **Step 2:** 实现改动(Matchmaker 注入、CreateRoomForSeats、playerCount 解析、Poll 接入 DrainAndTick)。保留 2b-1 既有用例全绿(单人 hello 仍走「凑齐 1 即建房」——hello playerCount=1Matchmaker 立即成形)。
- [ ] **Step 3:** GREEN,且 2b-1 既有 Gateway 测试不回归。
---
## Task 7: 端到端(真 Kestrel WS2 真人 + AI 兜底)
**Files:** Test `Server/Gateway.Tests/RpsEndToEndTests.cs`
- [ ] **Step 1:** 写集成测试(真 Kestrel + 2 个 ClientWebSocket):
- 两客户端连接、各发 MatchRequest(rps,1) → 各收 MatchFound → 各发出招(ChoiceOpcode Game 帧)→ 持续收快照 → 最终收 RoomEnd。断言双方进入同一房间、对局推进、收到结束。
- 单客户端 + 大 tick 间隔让超时触发 → 收 MatchFound(含 AI)→ 收快照(AI 在出招)→ 收 RoomEnd。
- tick 间隔选择:为缩短 60s 对局,测试用**较大 dt**(如每 tick dt=10f6 个 tick 即结束)或把 RPS 总时长经 RoomConfig 注入(本计划用大 dt 推进,避免改 Core 常量)。
- [ ] **Step 2:** GREEN,两次运行无抖动。
> dt 说明:服务端 tick 循环按 `tickIntervalMs` 真实节奏调 `TickAll(dt)`,但 dt 是逻辑步长。测试可设较大 tickIntervalMs + 较大 dt 让 RPS 的 60s 在数个 tick 内走完(RPS.Core 用累积秒判定,纯逻辑,与真实墙钟解耦)。
---
## Task 8: 收尾——全量验证(服务端)
- [ ] **Step 1:** `bash Server/build-test-games.sh && dotnet test Server/Server.sln` 全绿(94 + RPSRpsLogic 8 + RpsServerRoom + RpsModuleLoad + Matchmaker + ServerLoopMatchmaking + RpsEndToEnd)。
- [ ] **Step 2:** `dotnet build Server/Server.sln -v minimal` → 0 error / 0 warning。
---
## Task 9: [Unity 人工核对] RPS.Client 表现层
**Files:** Create `Client/Assets/RockPaperScissors/csharp/RpsGameClient.cs`namespace `RPS.Client`,热更,引用 Framework.Shared + RPS.Core)。
> 实现 `IGameClient``OnEnter(ctx)` 加载 UI prefab`ctx.Assets.Load("Assets/Game/Art/UI/Prefab/UI_RockPaperScissors.prefab")`)、挂到 Main`OnNetMessage(msg)` 解码 `RpsLogic.Decode` 渲染回合/计时/比分;三个出招按钮 → `ctx.Send(new NetMessage(RpsServerRoom.ChoiceOpcode, payload))``OnUpdate(dt)` 刷新倒计时;`OnExit` 清理 UI。具体代码按现有 RPS Lua UI 结构(`UI_RockPaperScissors.prefab` 既有)改写为 C#,用第 3 步 `MiniGameHost` 加载。
- [ ] **Step 1:**`RpsGameClient.cs`(参照原 `RockPaperScissors/script/Client/main.lua` 的 UI 节点结构与渲染逻辑,转为 C# + `IGameClient`)。
- [ ] **Step 2: [Unity 人工核对]** 用第 3 步 `MiniGameHost.EnterGame(cdn, "rps", 1, sock)` 加载,连接本步骤服务端,端到端验证:匹配(或 AI 兜底)→ 出招 → 看到对手出招/比分/倒计时 → 结算界面 → 返回大厅。验证「写一个新小游戏 = 框架零改动」。
---
## 完成判据
- [ ] **服务端(可 TDD)全绿**RPS.Core 规则/编解码/确定性、RPS.Server 房间/AI、ALC 加载 RPS、Matchmaker 分桶/超时 AI、ServerLoop 匹配建房、真 WS 2 人 + AI 兜底端到端。`dotnet build` 0 警告。
- [ ] 「框架零改动」验证:RPS 经与 Hello 相同的 ALC + games 约定加载,框架(Framework.Shared/RoomHost/Gateway)未为 RPS 改任何接口(仅 ServerLoop 接入通用 Matchmaker,非 RPS 专属)。
- [ ] 一份 Core 两端共用:RPS.Core 同时被服务端(ALC 加载)与客户端(HybridCLR 加载)使用,编解码一份。
- [ ] [Unity 人工核对] RPS.Client 端到端跑通匹配→对局→结算→返回大厅。
## 不在本步骤范围 / 风险
- 真实结算落库/奖励发放(设计 §3.3 OnRoomEnd 落库)—— 本步骤 RPS.Server 只产出胜负,奖励接口接 2a `IStorage`/后续持久化。
- 断线重连在对局中的 RPS 体验(2b-1 已有会话重连;RPS 状态由服务端权威,重连后下个快照即校正)。
- 微信 WASM 热更合规(§6.6)。
- RPS.Client 的 UI 细节按既有 prefab 结构实现(Unity 人工)。