# 用 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` + `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 Unity(HybridCLR 热更,第 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(契约/协议)、2a(RoomHost/ALC/Hello 加载范式)、2b-1(Gateway/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.Shared(Private=false) | | `RPS.Core/RpsTypes.cs` | `Choice`/`RpsPhase`/`RpsState`/`RpsInput`/`RpsEvent` | | `RPS.Core/RpsLogic.cs` | `IGameLogic`:选择/计时/判定/结算 + 编解码 | | `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` 加 ``。这只让**测试**直接引用 Core 做单元测试;运行时服务端仍通过 ALC 动态加载(与之并不冲突)。 - [ ] **Step 1: 写 `RPS.Core.csproj`** ```xml netstandard2.1 9.0 disable RPS.Core RPS.Core false ``` - [ ] **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; // RoundResolved:0/1 胜者座位,-1 平局 public int GameWinner; // GameFinished:0/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` → RED(RpsLogic 不存在)。 - [ ] **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 { 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 Step(RpsState state, RpsInput input, IRandom random) { var events = new List(); if (state.Phase == RpsPhase.Finished) return new StepResult(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(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(state, events); } private static readonly Choice[] All = { Choice.Rock, Choice.Paper, Choice.Scissors }; private void ResolveRound(RpsState s, IRandom rng, List 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 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 → GREEN(8 用例)。 --- ## 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`,按顺序映射 playerId→座位 0/1,记录 `IsAI`。`OnMessage`:解出 Choice,`Core.Step(Choose)`。`OnTick`:Choosing 阶段给 AI 座位 `ctx.Random` 出招;`Core.Step(Tick,dt)`;广播快照(opcode=1,payload=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 自动出招,对局能推进并结算。 - (断言通过解码广播的快照 payload(RpsLogic.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 _seatOf = new Dictionary(); // playerId->seat private bool _ended; public void OnRoomStart(IReadOnlyList 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`,OnRoomStart(2 座位,其一 IsAI)+ 多次 OnTick→广播快照可解码、能推进到结算。证明「框架零改动加载新游戏」(与 Hello 同范式)。 - [ ] **Step 2:** GREEN。 --- ## Task 5: 匹配器 Matchmaker(TDD) **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 AI(IsAI=true,pid 为负)的 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 Seats = new List(); } private sealed class Waiter { public int Pid; public long EnqueuedTick; } private sealed class Bucket { public int PlayerCount; public List Waiters = new List(); } private readonly long _timeoutTicks; private int _aiSeq; private readonly Dictionary _buckets = new Dictionary(); 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 Poll(long currentTick) { List 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()).Add(m); } } return formed ?? (IReadOnlyList)System.Array.Empty(); } } } ``` - [ ] **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.RoomId,AI 座位无 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=1,Matchmaker 立即成形)。 - [ ] **Step 3:** GREEN,且 2b-1 既有 Gateway 测试不回归。 --- ## Task 7: 端到端(真 Kestrel WS:2 真人 + 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=10f,6 个 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 + RPS:RpsLogic 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 人工)。