Files
AIC-Project/docs/superpowers/plans/2026-06-19-rps-rewrite-matchmaking-step5.md
T
2026-07-10 10:24:29 +08:00

633 lines
31 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 用 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 人工)。