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

31 KiB
Raw Permalink Blame History

用 C# 重写 RockPaperScissors + 匹配 + AI 兜底(§9 第 5 步,试金石)实现计划

For agentic workers: REQUIRED SUB-SKILL: superpowers:subagent-driven-development / executing-plans. Steps use - [ ].

服务端全部可 TDDRPS.Core / RPS.Server / Matchmaker / AI 兜底 / 真 Kestrel WS 端到端,dotnet test)。RPS.Client 是 Unity(标注「[Unity 人工核对]」)。

Goal: 把石头剪刀布从 Lua 重写为 C#:RPS.Core(纯规则 IGameLogic+ RPS.ServerIGameServerRoom,含 AI 出招)+ 服务端匹配(按 (gameId,version) 分桶、凑齐 2 人建房、15s 超时 AI 兜底)+ RPS.ClientIGameClient 表现层,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 的 IClientConnectionRPS.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/scissorsrock>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#Projectdotnet 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 建房
RoomHost2a,复用) 多人房间已支持

客户端(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
<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
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/
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 的 ProjectReferencebash Server/build-test-games.sh && dotnet test Server/Server.sln --filter RpsLogicTests → REDRpsLogic 不存在)。

  • Step 5: 写 RpsLogic.cs

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,记录 IsAIOnMessage:解出 ChoiceCore.Step(Choose)OnTickChoosing 阶段给 AI 座位 ctx.Random 出招;Core.Step(Tick,dt);广播快照(opcode=1payload=Core.Encode);Finished→ctx.EndRoom

  • Step 1: 写 RPS.Server.csprojnetstandard2.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
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.dllServer/Server.Host.Tests/TestGames/rps/1/,写 game.jsongameId=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() 得到 IGameServerRoomOnRoomStart2 座位,其一 IsAI+ 多次 OnTick→广播快照可解码、能推进到结算。证明「框架零改动加载新游戏」(与 Hello 同范式)。
  • Step 2: GREEN。

Task 5: 匹配器 MatchmakerTDD

Files: Create Server/Gateway/Matchmaker.csTest 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: 实现
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.csTest 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.jsonplayerCount(宿主可加一个轻量读取,或 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.csnamespace RPS.Client,热更,引用 Framework.Shared + RPS.Core)。

实现 IGameClientOnEnter(ctx) 加载 UI prefabctx.Assets.Load("Assets/Game/Art/UI/Prefab/UI_RockPaperScissors.prefab"))、挂到 MainOnNetMessage(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 人工)。