Initial commit: Client Doc docs Server Tools
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,591 @@
|
||||
# 结算结果传递(RoomEndResult)实施计划
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 把小游戏的结算结果(胜者 pid + 比分文本)从 `RpsServerRoom` 经框架管道传到客户端结算弹框,替掉网关硬编码的 `WinnerPlayerId=0, ResultBlob=empty`。
|
||||
|
||||
**Architecture:** `IRoomCtx.EndRoom(RoomEndResult)` 新重载 → `Room.EndResult` 捕获 → `RoomHost.TickAll` 返回 `EndedRoom{RoomId,Result}` → `ServerLoop.OnRoomEnded` 填入既有 `RoomEndMsg` → 客户端 `MiniGameHost.ShowResultDialog` 按 blob 文本显示。零协议改动。spec:`docs/superpowers/specs/2026-07-09-room-end-result-design.md`。
|
||||
|
||||
**Tech Stack:** netstandard2.1 C#9(Framework.Shared/RPS)、net10.0 + xUnit(服务端与测试)、Unity 2022.3 UGUI(客户端,本环境仅 dotnet 编译验证)。
|
||||
|
||||
**重要约定:本仓库 `.git` 为空目录(非可用 git 仓库),用户已定"不用 git,只改代码跑测试"。所有"Commit"步骤替换为"跑测试确认绿"。**
|
||||
|
||||
**关键背景(给零上下文工程师):**
|
||||
- 一份共享源码 `Client/Assets/Framework/Shared/` 同时被客户端(Unity)与服务端(`Server/Framework.Shared.csproj` 链接编译)使用。
|
||||
- 游戏 DLL 只**消费**不**实现** `IRoomCtx`(全仓库唯一实现是 `Server/Server.Host/RoomCtx.cs`),给接口加成员不破坏已发布模块。
|
||||
- 测试夹具 `Server/Server.Host.Tests/TestGames/rps/{1,2}/` 由 `Server/build-test-games.sh` 从 RPS 源码构建,RPS.Server 改动后必须重跑该脚本,否则 RoomHost/Gateway 相关测试跑的还是旧 DLL。
|
||||
- 所有 dotnet 命令在仓库根 `D:/UD/AI/AIC#Project` 执行(路径含 `#`,bash 中加引号)。
|
||||
|
||||
---
|
||||
|
||||
### Task 1: 共享接口 + RoomCtx/Room 结果管道(Server.Host 层)
|
||||
|
||||
**Files:**
|
||||
- Modify: `Client/Assets/Framework/Shared/IGameServerRoom.cs`
|
||||
- Modify: `Server/Server.Host/RoomCtx.cs`
|
||||
- Modify: `Server/Server.Host/Room.cs`(`RequestEnd`)
|
||||
- Modify: `Server/Server.Host/RoomHost.cs:41`(仅回调接线一行)
|
||||
- Test: `Server/Server.Host.Tests/RoomCtxTests.cs`、`Server/Server.Host.Tests/RoomTests.cs`
|
||||
- Modify(编译修复): `Server/Server.Host.Tests/RpsServerRoomTests.cs:49`(lambda 形参)
|
||||
|
||||
- [ ] **Step 1: 写失败测试**
|
||||
|
||||
`RoomCtxTests.cs` 追加两个测试(类内任意位置);同时把现有 `EndRoom_InvokesCallback`(第 39 行)和 `Broadcast_And_SendTo_RouteToOutput`(第 23 行)的 endRoom lambda 从 `() => ...` 改为 `_ => ...`(形参类型将变为 `Action<RoomEndResult>`):
|
||||
|
||||
```csharp
|
||||
[Fact]
|
||||
public void EndRoom_WithResult_PassesResultToCallback()
|
||||
{
|
||||
RoomEndResult seen = null;
|
||||
var ctx = new RoomCtx(new DeterministicRandom(1), new ManualClock(),
|
||||
new ConsoleLogger(), new InMemoryStorage(), new FakeOutput(), r => seen = r);
|
||||
var result = new RoomEndResult { WinnerPlayerId = 42, ResultBlob = new byte[] { 1 } };
|
||||
ctx.EndRoom(result);
|
||||
Assert.Same(result, seen);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EndRoom_Parameterless_PassesNull()
|
||||
{
|
||||
var seen = new RoomEndResult(); // 先放非 null,验证回调真的收到 null
|
||||
var ctx = new RoomCtx(new DeterministicRandom(1), new ManualClock(),
|
||||
new ConsoleLogger(), new InMemoryStorage(), new FakeOutput(), r => seen = r);
|
||||
ctx.EndRoom();
|
||||
Assert.Null(seen);
|
||||
}
|
||||
```
|
||||
|
||||
`RoomTests.cs` 追加(类内任意位置)一个 fake 房间与两个测试;同时把 `Build` helper(第 50 行)的 `() => room.RequestEnd()` 改为 `r => room.RequestEnd(r)`:
|
||||
|
||||
```csharp
|
||||
// 结束时带结算结果的房间
|
||||
private sealed class ResultRoom : IGameServerRoom
|
||||
{
|
||||
public RoomEndResult Result;
|
||||
private IRoomCtx _ctx;
|
||||
public void OnRoomStart(IReadOnlyList<PlayerInfo> players, IRoomCtx ctx) => _ctx = ctx;
|
||||
public void OnMessage(int playerId, NetMessage message) { }
|
||||
public void OnTick(float dt) => _ctx.EndRoom(Result);
|
||||
public void OnRoomEnd() { }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EndRoomWithResult_IsCapturedOnRoom()
|
||||
{
|
||||
var result = new RoomEndResult { WinnerPlayerId = 7, ResultBlob = new byte[] { 9 } };
|
||||
var (room, _, _) = Build("rr", new ResultRoom { Result = result });
|
||||
room.Start(Players);
|
||||
room.Tick(1f);
|
||||
Assert.False(room.IsActive);
|
||||
Assert.Same(result, room.EndResult);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EndRoomWithoutResult_EndResultIsNull()
|
||||
{
|
||||
var (room, _, _) = Build("rn", new CountingRoom()); // CountingRoom 调无参 EndRoom
|
||||
room.Start(Players);
|
||||
room.Tick(1f);
|
||||
room.Tick(1f); // 第 2 tick 触发 EndRoom()
|
||||
Assert.False(room.IsActive);
|
||||
Assert.Null(room.EndResult);
|
||||
}
|
||||
```
|
||||
|
||||
`RpsServerRoomTests.cs` 第 49 行 `() => ended[0] = true` 改为 `_ => ended[0] = true`(仅编译修复,该文件的功能改动在 Task 3)。
|
||||
|
||||
- [ ] **Step 2: 跑测试确认失败(RED)**
|
||||
|
||||
Run: `cd "D:/UD/AI/AIC#Project/Server" && dotnet test Server.Host.Tests --filter "EndRoomWithResult|EndRoom_WithResult" 2>&1 | tail -15`
|
||||
Expected: **编译错误**(`RoomEndResult` 不存在、`Room.EndResult` 不存在)——接口未实现前编译失败即 RED。
|
||||
|
||||
- [ ] **Step 3: 实现**
|
||||
|
||||
`Client/Assets/Framework/Shared/IGameServerRoom.cs` 全文替换为:
|
||||
|
||||
```csharp
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace XWorld.Framework
|
||||
{
|
||||
// 房间结算结果:由小游戏逻辑在 EndRoom 时给出,经网关放入 RoomEndMsg 下发客户端。
|
||||
public sealed class RoomEndResult
|
||||
{
|
||||
public int WinnerPlayerId; // 0 = 平局/无胜者(与 RoomEndMsg 语义一致)
|
||||
public byte[] ResultBlob; // 小游戏自定义结算数据;放 UTF-8 文本时框架结算弹框直接显示
|
||||
}
|
||||
|
||||
public interface IRoomCtx
|
||||
{
|
||||
IRandom Random { get; } // 权威随机
|
||||
ITimer Timer { get; }
|
||||
ILogger Logger { get; }
|
||||
IStorage Storage { get; }
|
||||
void Broadcast(NetMessage message);
|
||||
void SendTo(int playerId, NetMessage message);
|
||||
void EndRoom(); // 由小游戏逻辑请求结束本房间(无结算结果)
|
||||
void EndRoom(RoomEndResult result); // 带结算结果结束;result 可为 null,等价无参
|
||||
}
|
||||
|
||||
public interface IGameServerRoom
|
||||
{
|
||||
void OnRoomStart(IReadOnlyList<PlayerInfo> players, IRoomCtx ctx);
|
||||
void OnMessage(int playerId, NetMessage message);
|
||||
void OnTick(float deltaTime); // 10-20Hz,内部调用 Core.Step + 广播快照
|
||||
void OnRoomEnd();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`Server/Server.Host/RoomCtx.cs`:字段与构造参数 `Action _endRoom` → `Action<RoomEndResult> _endRoom`;末尾两方法:
|
||||
|
||||
```csharp
|
||||
public void EndRoom() => _endRoom(null);
|
||||
public void EndRoom(RoomEndResult result) => _endRoom(result);
|
||||
```
|
||||
|
||||
`Server/Server.Host/Room.cs`:`RequestEnd` 处(第 24-26 行)改为:
|
||||
|
||||
```csharp
|
||||
public RoomEndResult EndResult { get; private set; }
|
||||
|
||||
// 由 RoomCtx.EndRoom 回调(或宿主)触发;下一次 tick 末尾或 Start 后据此结束。
|
||||
// 多次调用以最后一次的 result 为准。
|
||||
public void RequestEnd() => RequestEnd(null);
|
||||
public void RequestEnd(RoomEndResult result) { _endRequested = true; EndResult = result; }
|
||||
```
|
||||
|
||||
`Server/Server.Host/RoomHost.cs` 第 41 行:`output, () => room.RequestEnd());` → `output, r => room.RequestEnd(r));`
|
||||
|
||||
- [ ] **Step 4: 跑测试确认通过(GREEN)**
|
||||
|
||||
Run: `cd "D:/UD/AI/AIC#Project/Server" && dotnet test Server.Host.Tests 2>&1 | tail -3`
|
||||
Expected: 全部通过(57 旧 + 4 新 = 61 附近,0 失败)。
|
||||
|
||||
- [ ] **Step 5: 全 sln 回归**
|
||||
|
||||
Run: `cd "D:/UD/AI/AIC#Project/Server" && dotnet test Server.sln 2>&1 | tail -5`
|
||||
Expected: 全绿(Gateway 此时尚未用到新返回值,不受影响;若 Gateway 编译错说明改了不该改的东西)。
|
||||
|
||||
---
|
||||
|
||||
### Task 2: RoomHost.TickAll 带结果返回 + 网关接线
|
||||
|
||||
**Files:**
|
||||
- Modify: `Server/Server.Host/RoomHost.cs`(`TickAll` + 新 `EndedRoom` struct)
|
||||
- Modify: `Server/Gateway/ServerLoop.cs:79-80, 405, 408-421`
|
||||
- Test: `Server/Server.Host.Tests/RoomHostTests.cs:77-90`(适配新返回类型)
|
||||
|
||||
说明:`TickAll` 返回类型变更会同时破坏 `ServerLoop` 与 `RoomHostTests` 编译,故三处一个任务内完成。结果真值(非 null)的断言在 Task 3 夹具重建后补,本任务先保证管道形状与零回归。
|
||||
|
||||
- [ ] **Step 1: 适配测试(先改测试即 RED——编译失败)**
|
||||
|
||||
`RoomHostTests.cs` 的 `TickAll_ReturnsEndedRoomIds`(第 77-90 行)改为:
|
||||
|
||||
```csharp
|
||||
[Fact]
|
||||
public void TickAll_ReturnsEndedRoomIds()
|
||||
{
|
||||
var host = NewHost();
|
||||
host.CreateRoom("E", "rps", 1, Cfg(), Players, new Capture());
|
||||
|
||||
var first = host.TickAll(10f); // 第一大 tick:TotalElapsed=10 < 60,不结束
|
||||
Assert.Empty(first);
|
||||
|
||||
var ended = new List<EndedRoom>();
|
||||
for (int i = 0; i < 10 && !ended.Exists(e => e.RoomId == "E"); i++)
|
||||
ended.AddRange(host.TickAll(10f));
|
||||
Assert.Contains(ended, e => e.RoomId == "E"); // 累计超过 60s 后该 tick 返回结束房间
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 跑测试确认失败(RED)**
|
||||
|
||||
Run: `cd "D:/UD/AI/AIC#Project/Server" && dotnet test Server.Host.Tests --filter "TickAll_ReturnsEndedRoomIds" 2>&1 | tail -8`
|
||||
Expected: 编译错误(`EndedRoom` 不存在)。
|
||||
|
||||
- [ ] **Step 3: 实现 RoomHost**
|
||||
|
||||
`Server/Server.Host/RoomHost.cs`:namespace 内(RoomHost 类之前)加:
|
||||
|
||||
```csharp
|
||||
// TickAll 的结束房间条目:携带小游戏经 EndRoom(result) 给出的结算结果(可为 null)
|
||||
public readonly struct EndedRoom
|
||||
{
|
||||
public readonly string RoomId;
|
||||
public readonly RoomEndResult Result;
|
||||
public EndedRoom(string roomId, RoomEndResult result) { RoomId = roomId; Result = result; }
|
||||
}
|
||||
```
|
||||
|
||||
`TickAll`(第 58-71 行)替换为:
|
||||
|
||||
```csharp
|
||||
public IReadOnlyList<EndedRoom> TickAll(float dt)
|
||||
{
|
||||
List<EndedRoom> ended = null;
|
||||
foreach (var kv in _rooms)
|
||||
{
|
||||
var room = kv.Value.Room;
|
||||
if (!room.IsActive) { (ended ??= new List<EndedRoom>()).Add(new EndedRoom(kv.Key, room.EndResult)); continue; }
|
||||
room.Tick(dt);
|
||||
if (!room.IsActive) (ended ??= new List<EndedRoom>()).Add(new EndedRoom(kv.Key, room.EndResult));
|
||||
}
|
||||
if (ended != null)
|
||||
foreach (var e in ended) ReleaseRoom(e.RoomId);
|
||||
return ended ?? (IReadOnlyList<EndedRoom>)System.Array.Empty<EndedRoom>();
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 实现 ServerLoop 接线**
|
||||
|
||||
`Server/Gateway/ServerLoop.cs` 三处:
|
||||
|
||||
第 79-80 行:
|
||||
|
||||
```csharp
|
||||
var ended = _host.TickAll(dt);
|
||||
for (int i = 0; i < ended.Count; i++) OnRoomEnded(ended[i].RoomId, ended[i].Result);
|
||||
```
|
||||
|
||||
第 405 行("Start 即结束"路径,`CreateRoomForSeats` 持有 `Room` 引用):
|
||||
|
||||
```csharp
|
||||
if (!room.IsActive) OnRoomEnded(roomId, room.EndResult); // 极端:Start 即结束
|
||||
```
|
||||
|
||||
`OnRoomEnded`(第 408-421 行)替换为:
|
||||
|
||||
```csharp
|
||||
private void OnRoomEnded(string roomId, RoomEndResult result)
|
||||
{
|
||||
if (!_roomPlayers.TryGetValue(roomId, out var players)) return;
|
||||
_roomPlayers.Remove(roomId);
|
||||
var msg = new RoomEndMsg
|
||||
{
|
||||
WinnerPlayerId = result?.WinnerPlayerId ?? 0,
|
||||
ResultBlob = result?.ResultBlob ?? Array.Empty<byte>()
|
||||
};
|
||||
byte[] payload = msg.Encode();
|
||||
for (int i = 0; i < players.Count; i++)
|
||||
{
|
||||
int pid = players[i];
|
||||
SendFramework(pid, FrameworkOpcode.RoomEnd, payload);
|
||||
var s = _sessions.Get(pid);
|
||||
if (s != null) s.RoomId = null;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: 跑测试确认通过(GREEN)+ 全 sln 回归**
|
||||
|
||||
Run: `cd "D:/UD/AI/AIC#Project/Server" && dotnet test Server.sln 2>&1 | tail -5`
|
||||
Expected: 全绿(此时结果恒为 null——RPS 夹具还是旧 DLL,调的无参 EndRoom;行为与改动前完全一致)。
|
||||
|
||||
---
|
||||
|
||||
### Task 3: RPS 服务端产出结果 + 夹具重建 + 端到端断言
|
||||
|
||||
**Files:**
|
||||
- Modify: `Server/games-src/RPS/RPS.Server/RpsServerRoom.cs`
|
||||
- Test: `Server/Server.Host.Tests/RpsServerRoomTests.cs`(BuildCtx 捕获结果 + 2 新测试)
|
||||
- Test: `Server/Server.Host.Tests/RoomHostTests.cs`(1 新测试,夹具重建后结果非 null)
|
||||
- Test: `Server/Gateway.Tests/RpsEndToEndTests.cs`(RoomEnd 携带结算断言)
|
||||
- Run: `Server/build-test-games.sh`(重建 TestGames/rps 夹具)
|
||||
|
||||
- [ ] **Step 1: 写失败测试(单元层)**
|
||||
|
||||
`RpsServerRoomTests.cs`:`BuildCtx`(第 38-51 行)改为四元组、捕获结果:
|
||||
|
||||
```csharp
|
||||
private static (RoomCtx ctx, FakeOutput output, bool[] ended, RoomEndResult[] result)
|
||||
BuildCtx()
|
||||
{
|
||||
var output = new FakeOutput();
|
||||
var ended = new bool[1];
|
||||
var result = new RoomEndResult[1];
|
||||
var ctx = new RoomCtx(
|
||||
new DeterministicRandom(42),
|
||||
new ManualClock(),
|
||||
new ConsoleLogger(),
|
||||
new InMemoryStorage(),
|
||||
output,
|
||||
r => { ended[0] = true; result[0] = r; });
|
||||
return (ctx, output, ended, result);
|
||||
}
|
||||
```
|
||||
|
||||
现有 5 个测试的解构改四元组(多出的位置用 `_`):
|
||||
- `TwoHumans_PlayRound_ScoresUpdateByRules`:`var (ctx, output, _, _) = BuildCtx();`
|
||||
- `TwoHumans_Reach60s_EndsRoom`:`var (ctx, output, ended, _) = BuildCtx();`
|
||||
- `HumanAndAi_AutoAdvances_AndSettles`:`var (ctx, output, ended, _) = BuildCtx();`
|
||||
- `OnRoomStart_BroadcastsInitialSnapshot`:`var (ctx, output, _, _) = BuildCtx();`
|
||||
- `SecondChoiceFromSameSeat_IsIgnored`:`var (ctx, output, _, _) = BuildCtx();`
|
||||
|
||||
文件头部加 `using System.Text;`,追加两个新测试:
|
||||
|
||||
```csharp
|
||||
// ── 结算结果:胜者 pid + 比分文本经 EndRoom(result) 上报 ─────────
|
||||
|
||||
[Fact]
|
||||
public void Finish_ReportsWinnerPidAndScoreText()
|
||||
{
|
||||
var (ctx, _, ended, result) = BuildCtx();
|
||||
var room = new RpsServerRoom();
|
||||
room.OnRoomStart(TwoHumans, ctx);
|
||||
|
||||
// 每回合双方都出招(Rock vs Scissors → seat0 全胜),避免超时随机补招引入不确定性。
|
||||
// 每回合 10.2s,第 6 回合 reveal 后 TotalElapsed=61.2 ≥ 60 → Finished
|
||||
for (int round = 0; round < 6; round++)
|
||||
{
|
||||
room.OnMessage(10, ChoiceMsg(Choice.Rock));
|
||||
room.OnMessage(20, ChoiceMsg(Choice.Scissors));
|
||||
room.OnTick(5.1f); // 结算本回合 → Revealing
|
||||
room.OnTick(5.1f); // → 下一回合 Choosing(末回合触发 Finished)
|
||||
}
|
||||
|
||||
Assert.True(ended[0]);
|
||||
Assert.NotNull(result[0]);
|
||||
Assert.Equal(10, result[0].WinnerPlayerId); // seat0 的 pid
|
||||
Assert.Equal("最终比分 6 : 0", Encoding.UTF8.GetString(result[0].ResultBlob));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Finish_Draw_ReportsPidZeroAndDrawText()
|
||||
{
|
||||
var (ctx, _, ended, result) = BuildCtx();
|
||||
var room = new RpsServerRoom();
|
||||
room.OnRoomStart(TwoHumans, ctx);
|
||||
|
||||
// 双方每回合同出 Rock → 每回合平 → 总分 0:0 → Winner=2(平局)
|
||||
for (int round = 0; round < 6; round++)
|
||||
{
|
||||
room.OnMessage(10, ChoiceMsg(Choice.Rock));
|
||||
room.OnMessage(20, ChoiceMsg(Choice.Rock));
|
||||
room.OnTick(5.1f);
|
||||
room.OnTick(5.1f);
|
||||
}
|
||||
|
||||
Assert.True(ended[0]);
|
||||
Assert.NotNull(result[0]);
|
||||
Assert.Equal(0, result[0].WinnerPlayerId); // 平局 → 0
|
||||
Assert.Equal("平局 0 : 0", Encoding.UTF8.GetString(result[0].ResultBlob));
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 跑测试确认失败(RED)**
|
||||
|
||||
Run: `cd "D:/UD/AI/AIC#Project/Server" && dotnet test Server.Host.Tests --filter "Finish_Reports|Finish_Draw" 2>&1 | tail -10`
|
||||
Expected: FAIL——`result[0]` 为 null(RpsServerRoom 还在调无参 EndRoom)。注意:Server.Host.Tests 直接 ProjectReference RPS.Server 源码工程,不走夹具,所以这里能先红。
|
||||
|
||||
- [ ] **Step 3: 实现 RpsServerRoom**
|
||||
|
||||
`Server/games-src/RPS/RPS.Server/RpsServerRoom.cs`:
|
||||
|
||||
头部加 `using System.Text;`。
|
||||
|
||||
字段区(第 16 行 `_seatOf` 之后)加:
|
||||
|
||||
```csharp
|
||||
private readonly int[] _pidOfSeat = new int[2]; // seat->playerId(结算用;AI 也有 pid)
|
||||
```
|
||||
|
||||
`OnRoomStart` 循环体(第 23-27 行)改为:
|
||||
|
||||
```csharp
|
||||
for (int i = 0; i < players.Count && i < 2; i++)
|
||||
{
|
||||
_seatOf[players[i].PlayerId] = i;
|
||||
_pidOfSeat[i] = players[i].PlayerId;
|
||||
_state.IsAi[i] = players[i].IsAI;
|
||||
}
|
||||
```
|
||||
|
||||
`OnTick` 的结束分支(第 56-61 行)改为:
|
||||
|
||||
```csharp
|
||||
if (_state.Phase == RpsPhase.Finished)
|
||||
{
|
||||
_ended = true;
|
||||
int s0 = _state.Scores[0], s1 = _state.Scores[1];
|
||||
bool draw = _state.Winner == 2;
|
||||
_ctx.Logger.Info($"rps finished, winner seat={_state.Winner}, scores {s0}:{s1}");
|
||||
_ctx.EndRoom(new RoomEndResult
|
||||
{
|
||||
WinnerPlayerId = draw ? 0 : _pidOfSeat[_state.Winner],
|
||||
ResultBlob = Encoding.UTF8.GetBytes(draw ? $"平局 {s0} : {s1}" : $"最终比分 {s0} : {s1}")
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 跑测试确认通过(GREEN)**
|
||||
|
||||
Run: `cd "D:/UD/AI/AIC#Project/Server" && dotnet test Server.Host.Tests 2>&1 | tail -3`
|
||||
Expected: 全绿。
|
||||
|
||||
- [ ] **Step 5: 重建测试夹具**
|
||||
|
||||
Run: `cd "D:/UD/AI/AIC#Project/Server" && bash build-test-games.sh`
|
||||
Expected: `built rps v1 -> .../TestGames/rps/1`、`built rps v2 -> ...`、`TestGames ready.`(脚本内含 Framework.Shared.dll 不泄漏断言,失败会 exit 1)。
|
||||
|
||||
- [ ] **Step 6: 夹具层结果断言(RoomHostTests)**
|
||||
|
||||
`RoomHostTests.cs` 追加(1 真人 + 1 AI 随机对局,可能平局,故只断言 blob 非空):
|
||||
|
||||
```csharp
|
||||
[Fact]
|
||||
public void TickAll_EndedRoom_CarriesResult()
|
||||
{
|
||||
var host = NewHost();
|
||||
host.CreateRoom("R", "rps", 1, Cfg(), Players, new Capture());
|
||||
|
||||
RoomEndResult result = null;
|
||||
bool found = false;
|
||||
for (int i = 0; i < 10 && !found; i++)
|
||||
foreach (var e in host.TickAll(10f))
|
||||
if (e.RoomId == "R") { found = true; result = e.Result; }
|
||||
|
||||
Assert.True(found);
|
||||
Assert.NotNull(result); // 夹具已重建,RPS 走 EndRoom(result)
|
||||
Assert.NotNull(result.ResultBlob);
|
||||
Assert.NotEmpty(result.ResultBlob); // "最终比分 x : y" 或 "平局 x : y"
|
||||
}
|
||||
```
|
||||
|
||||
Run: `cd "D:/UD/AI/AIC#Project/Server" && dotnet test Server.Host.Tests --filter "TickAll_EndedRoom_CarriesResult" 2>&1 | tail -5`
|
||||
Expected: PASS(若 FAIL 且 result 为 null,说明 Step 5 夹具没重建成功)。
|
||||
|
||||
- [ ] **Step 7: 端到端断言(Gateway.Tests)**
|
||||
|
||||
`RpsEndToEndTests.cs` 两个测试各加 RoomEnd 内容断言:
|
||||
|
||||
`TwoHumans_MatchAndPlayToRoomEnd`:第 53 行附近加变量 `RoomEndMsg c1End = null, c2End = null;`;t1 的 RoomEnd 分支(第 78-81 行)改:
|
||||
|
||||
```csharp
|
||||
else if (f.Channel == Channel.Framework && f.Opcode == (ushort)FrameworkOpcode.RoomEnd)
|
||||
{
|
||||
c1End = RoomEndMsg.Decode(f.Payload);
|
||||
c1RoomEnd = true;
|
||||
}
|
||||
```
|
||||
|
||||
t2 同样改(`c2End = RoomEndMsg.Decode(f.Payload);`)。末尾断言区(第 118-119 行后)加:
|
||||
|
||||
```csharp
|
||||
// 结算结果:blob 非占位(RPS 填入 UTF-8 比分文本),双端一致
|
||||
Assert.NotNull(c1End);
|
||||
Assert.NotNull(c2End);
|
||||
Assert.True(c1End.ResultBlob != null && c1End.ResultBlob.Length > 0, "RoomEnd 应携带结算 blob");
|
||||
Assert.Equal(c1End.WinnerPlayerId, c2End.WinnerPlayerId);
|
||||
Assert.Contains(":", System.Text.Encoding.UTF8.GetString(c1End.ResultBlob)); // 比分/平局文本
|
||||
```
|
||||
|
||||
(不断言具体胜者:双方每快照重发出招,回合是否踩到 Choosing 窗口受 tick 时序影响,可能随机补招/平局。确定性胜负已在 `RpsServerRoomTests` 用 ManualClock 断言。)
|
||||
|
||||
`SingleClient_AiFill_MatchAndPlayToRoomEnd`:加变量 `RoomEndMsg end = null;`,RoomEnd 分支(第 170-173 行)改 `end = RoomEndMsg.Decode(f.Payload); roomEnd = true;`,末尾断言(第 178 行后)加:
|
||||
|
||||
```csharp
|
||||
Assert.NotNull(end);
|
||||
Assert.True(end.ResultBlob != null && end.ResultBlob.Length > 0, "AI 对局的 RoomEnd 也应携带结算 blob");
|
||||
```
|
||||
|
||||
- [ ] **Step 8: 跑 Gateway 测试确认通过 + 全 sln 回归**
|
||||
|
||||
Run: `cd "D:/UD/AI/AIC#Project/Server" && dotnet test Gateway.Tests 2>&1 | tail -3`
|
||||
Expected: 全绿(注意 Gateway.Tests 的 TestGames 由构建从 Server.Host.Tests/TestGames 拷贝,Step 5 已更新源头)。
|
||||
|
||||
Run: `cd "D:/UD/AI/AIC#Project/Server" && dotnet test Server.sln 2>&1 | tail -5`
|
||||
Expected: 全绿。
|
||||
|
||||
---
|
||||
|
||||
### Task 4: 客户端结算弹框显示结果(MiniGameHost)
|
||||
|
||||
**Files:**
|
||||
- Modify: `Client/Assets/Script/xmain/MiniGame/MiniGameHost.cs`(`ShowResultDialog`/`ResultTitle`/`ResultMessage`)
|
||||
- Verify: `dotnet build Client/HotUpdateGames/MiniGameRuntime.Verify`(编译验证壳,整目录 `<Compile Include>` MiniGame 源码;csproj 内 UnityEditorPath 默认值即本机 2022.3.62f3)
|
||||
|
||||
说明:本环境无 Unity,无法运行时验证;用编译验证壳保证 0 错,行为由用户 Unity Play 人工核对。客户端只消费 `RoomEndMsg`(既有类型),不引用 `RoomEndResult`,故对 `Library/ScriptAssemblies` 里的旧 Framework.Shared.dll 无依赖。
|
||||
|
||||
- [ ] **Step 1: 修改 ShowResultDialog(结算文本只解一次,标题/正文共用)**
|
||||
|
||||
`MiniGameHost.cs` `ShowResultDialog` 中,在 `_resultDialog = new GameObject(...)` 之前加一行:
|
||||
|
||||
```csharp
|
||||
string resultText = DecodeResultBlob(roomEnd.ResultBlob);
|
||||
```
|
||||
|
||||
Title/Message 两处 CreateText 调用改为传入 `resultText`:
|
||||
|
||||
```csharp
|
||||
Text title = CreateText("Title", panel.transform, ResultTitle(roomEnd, resultText), 34, FontStyle.Bold, TextAnchor.MiddleCenter);
|
||||
```
|
||||
|
||||
```csharp
|
||||
Text message = CreateText("Message", panel.transform, ResultMessage(roomEnd, resultText), 22, FontStyle.Normal, TextAnchor.MiddleCenter);
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 替换 ResultTitle / ResultMessage**
|
||||
|
||||
```csharp
|
||||
private string ResultTitle(RoomEndMsg roomEnd, string resultText)
|
||||
{
|
||||
if (roomEnd.WinnerPlayerId == 0)
|
||||
{
|
||||
// 有结算文本 = 游戏明确上报了"无胜者"→ 平局;无文本 = 旧模块/异常结束,保持中性标题
|
||||
return string.IsNullOrEmpty(resultText) ? "游戏结束" : "平局";
|
||||
}
|
||||
if (_selfPlayerId != 0 && roomEnd.WinnerPlayerId == _selfPlayerId)
|
||||
{
|
||||
return "胜利";
|
||||
}
|
||||
return "失败";
|
||||
}
|
||||
|
||||
private string ResultMessage(RoomEndMsg roomEnd, string resultText)
|
||||
{
|
||||
// 游戏上报的结算文本(如"最终比分 2 : 1")优先;裸 pid 对玩家无意义,不再显示
|
||||
if (!string.IsNullOrEmpty(resultText))
|
||||
{
|
||||
return resultText;
|
||||
}
|
||||
return string.IsNullOrEmpty(_roomId) ? $"{_gameId}@{_version}" : $"房间 {_roomId}";
|
||||
}
|
||||
```
|
||||
|
||||
(原 `ResultMessage` 里 `"\n胜利玩家:" + roomEnd.WinnerPlayerId` 的拼接随之删除;`DecodeResultBlob` 保持不变。)
|
||||
|
||||
- [ ] **Step 3: 编译验证**
|
||||
|
||||
Run: `cd "D:/UD/AI/AIC#Project/Client/HotUpdateGames/MiniGameRuntime.Verify" && dotnet build 2>&1 | tail -5`
|
||||
Expected: `0 个错误`(0 Errors)。若报 UnityEditorPath 相关缺 DLL,传 `-p:UnityEditorPath="<本机 Unity 2022.3.62f3 Editor 目录>"` 重试。
|
||||
|
||||
---
|
||||
|
||||
### Task 5: 全量回归 + 收尾
|
||||
|
||||
- [ ] **Step 1: 服务端全量测试**
|
||||
|
||||
Run: `cd "D:/UD/AI/AIC#Project/Server" && dotnet test Server.sln 2>&1 | tail -6`
|
||||
Expected: 全绿,总数 ≈ 旧基线 + 新增 8(RoomCtx 2、Room 2、RpsServerRoom 2、RoomHost 1、e2e 断言在既有测试内不增数)。
|
||||
|
||||
- [ ] **Step 2: 客户端验证壳复核**
|
||||
|
||||
Run: `cd "D:/UD/AI/AIC#Project/Client/HotUpdateGames/MiniGameRuntime.Verify" && dotnet build 2>&1 | tail -3`
|
||||
Expected: 0 错误。
|
||||
|
||||
- [ ] **Step 3: 告知用户的人工步骤(计划执行者原样转达,不代做)**
|
||||
|
||||
1. Unity Editor 打开工程让其重新编译(Framework.Shared 源码变了)。
|
||||
2. 菜单「XWorld/生成小游戏更新」重发 RPS 服务器模块到 `deploy/minigame/rps/<ver>/`(旧包不崩,但弹框无结算内容)。
|
||||
3. 客户端 `XWorld.Framework.Shared` + `XWorld.Link` 热更 DLL 重出(真机);Editor Play 本地即可验。
|
||||
4. Play 一局 RPS 核对:胜→「胜利」、负→「失败」、平→「平局」,正文比分文本,点「确定」正常退出。
|
||||
|
||||
---
|
||||
|
||||
## Self-Review 记录
|
||||
|
||||
- **Spec 覆盖**:§1 共享接口=Task 1;§2 宿主=Task 1+2;§3 网关=Task 2;§4 RPS=Task 3;§5 客户端=Task 4;测试章=各任务 TDD 步骤+Task 3 Step 6/7;部署影响=Task 5 Step 3。无缺口。
|
||||
- **占位扫描**:无 TBD/TODO;所有代码步骤含完整代码。
|
||||
- **类型一致性**:`RoomEndResult{WinnerPlayerId,ResultBlob}`、`Room.EndResult`、`RequestEnd(RoomEndResult)`、`EndedRoom{RoomId,Result}`、`OnRoomEnded(string,RoomEndResult)`、`ResultTitle/ResultMessage(RoomEndMsg,string)` 各任务间引用一致。
|
||||
- **确定性核对**:`Finish_ReportsWinnerPidAndScoreText` 每回合双方显式出招,无随机补招;6 回合 ×10.2s=61.2s≥60 触发 Finish,比分 6:0 确定。
|
||||
Reference in New Issue
Block a user