633 lines
31 KiB
Markdown
633 lines
31 KiB
Markdown
# 用 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`,`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<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; // 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<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.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<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: 实现**
|
||
```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.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 人工)。
|