31 KiB
用 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 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,dotnetSDK 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<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
<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; // RoundResolved:0/1 胜者座位,-1 平局
public int GameWinner; // GameFinished:0/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 的 ProjectReference)
bash Server/build-test-games.sh && dotnet test Server/Server.sln --filter RpsLogicTests→ RED(RpsLogic 不存在)。 -
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 → 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<PlayerInfo>,按顺序映射 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.SharedPrivate=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
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,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: 实现
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.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 build0 警告。 - 「框架零改动」验证: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 人工)。