1288 lines
49 KiB
Markdown
1288 lines
49 KiB
Markdown
# 删除 Hello + 完善 RPS 替代它 实施计划
|
||
|
||
> **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:** 彻底删除测试夹具 Hello,让剪刀石头布(RPS)同时承担"框架测试夹具"和"端到端示例游戏(含 C# 客户端)"两个角色,且 `dotnet test Server/Server.sln` 保持全绿。
|
||
|
||
**Architecture:** RPS 服务端(`RPS.Core`+`RPS.Server`)已完整且已有 RPS 专属测试(逻辑/房间/ALC加载/匹配/端到端)。本计划把"借 Hello 验证框架通用能力"的测试迁到 RPS(部分直接删冗余),用 `rps/1`+`rps/2`(同 IL、版本号不同)替代 Hello 的多版本夹具,新增对标 `HelloFlowGameClient` 的 RPS C# 客户端薄层,删除弃用 Lua,并把 PC 烟雾测试改用 RPS。
|
||
|
||
**Tech Stack:** C# / .NET 10(测试 xUnit)、netstandard2.1(游戏 DLL/客户端薄层)、bash 构建脚本、Unity 2022.3 + IMGUI(客户端,本环境仅 csc 编译验证)。
|
||
|
||
**重要约定(本仓库非 git):** 跳过所有 `git commit` 步骤;每个 Task 末尾以 `dotnet test` / `dotnet build` 的实际输出作为检查点。本环境**无 Unity**:客户端与 PC 烟雾的 Unity 行为无法 Play 验证,只能编译验证 + 标注人工核对。
|
||
|
||
---
|
||
|
||
## File Structure
|
||
|
||
**修改:**
|
||
- `Server/build-test-games.sh` — 去掉 Hello 构建,RPS 同时产出 `rps/1` 与 `rps/2`
|
||
- `Server/Server.Host.Tests/GameModuleLoaderTests.cs` — 去冗余,仅保留 rps 缺版本测试
|
||
- `Server/Server.Host.Tests/AlcUnloadTests.cs` — 改 rps
|
||
- `Server/Server.Host.Tests/GameModuleRegistryTests.cs` — 改 rps 1/2
|
||
- `Server/Server.Host.Tests/GameManifestTests.cs` — Valid 常量改 rps
|
||
- `Server/Server.Host.Tests/RoomHostTests.cs` — 重写为 rps 语义
|
||
- `Server/Gateway.Tests/ServerLoopTests.cs` — 删冗余 + 重连/在房重复匹配改 rps
|
||
- `Server/Gateway.Tests/GatewayEndToEndTests.cs` — 删冗余 + 重连改 rps
|
||
- `Server/Gateway.Tests/MatchmakerTests.cs` — playerCount=1 用例去 hello 标签
|
||
- `Server/Server.Host/GameManifest.cs` — 文档注释示例改 RPS
|
||
- `Tools/PcMiniGameSmoke/Build-PcMiniGameSmoke.ps1` — Hello → RPS
|
||
- `Client/Assets/Script/xmain/MiniGame/PcMiniGameSmokeLauncher.cs` — gameId/文案 hello → rps
|
||
- `Client/Assets/MiniGames/RockPaperScissors/README.md` — 状态更新
|
||
|
||
**新建:**
|
||
- `Client/Assets/MiniGames/RockPaperScissors/scripts/Client/RpsGameClient.cs` — C# 客户端薄层(唯一源码位)
|
||
- `Client/HotUpdateGames/RPS.Client/RPS.Client.csproj` — 链接上面源码做 csc 验证(对标 HelloFlow.Client)
|
||
|
||
**删除:**
|
||
- `Server/games-src/Hello/`(整目录)
|
||
- `Client/HotUpdateGames/HelloFlow.Client/`(整目录)
|
||
- `Server/Server.Host.Tests/TestGames/hello/`(夹具,及 bin 内拷贝)
|
||
- `Server/Server.Host.Tests/HotReloadEndToEndTests.cs`
|
||
- `Client/Assets/MiniGames/RockPaperScissors/Client/main.lua`、`.../Server/main.lua`、`.../scripts/Client/main.lua`、`.../scripts/Server/main.lua`(及 `.meta`)
|
||
|
||
---
|
||
|
||
## Task 1: 构建脚本去 Hello、RPS 产出 rps/1 + rps/2
|
||
|
||
**Files:**
|
||
- Modify: `Server/build-test-games.sh`(整文件替换)
|
||
|
||
- [ ] **Step 1: 用以下内容整体替换 `Server/build-test-games.sh`**
|
||
|
||
```bash
|
||
#!/usr/bin/env bash
|
||
set -euo pipefail
|
||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||
|
||
build_rps () {
|
||
local RPS_SRC="$ROOT/Server/games-src/RPS"
|
||
local RPS_BIN="$RPS_SRC/RPS.Server/bin/Release/netstandard2.1"
|
||
local TG="$ROOT/Server/Server.Host.Tests/TestGames/rps"
|
||
|
||
# 不使用 -o:-o 会绕过 Private=false,把 Framework.Shared.dll 也拷进来。
|
||
dotnet build "$RPS_SRC/RPS.Server/RPS.Server.csproj" -c Release --no-incremental >/dev/null
|
||
|
||
# 断言框架程序集未进入构建输出(Private=false 回归保险)
|
||
if [ -f "$RPS_BIN/XWorld.Framework.Shared.dll" ]; then
|
||
echo "ERROR: Framework.Shared.dll 出现在构建输出 $RPS_BIN —— Private=false 可能被破坏" >&2
|
||
exit 1
|
||
fi
|
||
for dll in RPS.Core.dll RPS.Server.dll; do
|
||
if [ ! -f "$RPS_BIN/$dll" ]; then
|
||
echo "ERROR: 构建后缺少 $dll" >&2
|
||
exit 1
|
||
fi
|
||
done
|
||
|
||
# rps/1 = 真实版本;rps/2 = 同一批 IL(仅 version 字段不同),供版本号/淘汰/refcount 测试
|
||
for ver in 1 2; do
|
||
local out="$TG/$ver"
|
||
rm -rf "$out"; mkdir -p "$out"
|
||
cp "$RPS_BIN/RPS.Core.dll" "$out/RPS.Core.dll"
|
||
cp "$RPS_BIN/RPS.Server.dll" "$out/RPS.Server.dll"
|
||
cat > "$out/game.json" <<EOF
|
||
{
|
||
"gameId": "rps",
|
||
"version": $ver,
|
||
"serverAssembly": "RPS.Server.dll",
|
||
"serverEntryType": "RPS.Server.RpsServerRoom",
|
||
"minFrameworkVersion": 1,
|
||
"playerCount": 2,
|
||
"tickRateHz": 10
|
||
}
|
||
EOF
|
||
echo "built rps v$ver -> $out"
|
||
done
|
||
}
|
||
|
||
build_rps
|
||
echo "TestGames ready."
|
||
```
|
||
|
||
- [ ] **Step 2: 运行构建脚本**
|
||
|
||
Run: `bash Server/build-test-games.sh`
|
||
Expected: 输出 `built rps v1 ...`、`built rps v2 ...`、`TestGames ready.`,无 ERROR。
|
||
|
||
- [ ] **Step 3: 确认夹具产出且无框架 DLL 泄漏**
|
||
|
||
Run: `ls Server/Server.Host.Tests/TestGames/rps/1 Server/Server.Host.Tests/TestGames/rps/2`
|
||
Expected: 两目录各含 `RPS.Core.dll`、`RPS.Server.dll`、`game.json`;无 `XWorld.Framework.Shared.dll`。
|
||
|
||
---
|
||
|
||
## Task 2: 删除 Hello 源码、夹具与 C# 客户端
|
||
|
||
**Files:**
|
||
- Delete: `Server/games-src/Hello/`、`Client/HotUpdateGames/HelloFlow.Client/`、`Server/Server.Host.Tests/TestGames/hello/`
|
||
|
||
- [ ] **Step 1: 删除 Hello 源码与客户端目录**
|
||
|
||
Run:
|
||
```bash
|
||
rm -rf "Server/games-src/Hello"
|
||
rm -rf "Client/HotUpdateGames/HelloFlow.Client"
|
||
rm -rf "Server/Server.Host.Tests/TestGames/hello"
|
||
```
|
||
|
||
- [ ] **Step 2: 清理测试输出目录里残留的 hello 夹具拷贝(PreserveNewest 不会自动删除已移除文件)**
|
||
|
||
Run:
|
||
```bash
|
||
find Server -type d -path "*/bin/*/TestGames/hello" -prune -exec rm -rf {} +
|
||
```
|
||
Expected: 无输出即成功(无残留或已删)。
|
||
|
||
- [ ] **Step 3: 确认 Hello 痕迹已无源码引用(仅测试 .cs 里还有字符串 "hello",下面任务逐个处理)**
|
||
|
||
Run: `grep -rln "Hello\." Server/games-src Client/HotUpdateGames 2>/dev/null || echo "no hello sources"`
|
||
Expected: `no hello sources`。
|
||
|
||
---
|
||
|
||
## Task 3: 删 HotReload 测试 + GameModuleLoaderTests 去冗余
|
||
|
||
`RpsModuleLoadTests` 已覆盖 "ALC 加载 + 跨界类型隔离 + 广播 + 推进到 Finished",故 `GameModuleLoaderTests` 的加载用例为冗余;`HotReloadEndToEndTests` 依赖两份不同 IL,按决策删除。
|
||
|
||
**Files:**
|
||
- Delete: `Server/Server.Host.Tests/HotReloadEndToEndTests.cs`
|
||
- Modify: `Server/Server.Host.Tests/GameModuleLoaderTests.cs`(整文件替换)
|
||
- Modify: `Server/Server.Host.Tests/RpsModuleLoadTests.cs:10`(去掉注释里残留的 Hello 字样)
|
||
|
||
- [ ] **Step 1: 删除 HotReload 测试文件**
|
||
|
||
Run: `rm "Server/Server.Host.Tests/HotReloadEndToEndTests.cs"`
|
||
|
||
- [ ] **Step 1b: 清理 `RpsModuleLoadTests.cs` 第 10 行注释里的 Hello 字样**
|
||
|
||
把:
|
||
```csharp
|
||
/// Task 4: Proves "framework zero-change loads a new game via ALC" — same paradigm as Hello.
|
||
```
|
||
改为:
|
||
```csharp
|
||
/// Proves "framework zero-change loads a new game via ALC": loads RPS through the existing ALC contract.
|
||
```
|
||
|
||
- [ ] **Step 2: 用以下内容整体替换 `GameModuleLoaderTests.cs`**
|
||
|
||
```csharp
|
||
using Xunit;
|
||
using XWorld.Server.Host;
|
||
|
||
namespace XWorld.Server.Host.Tests
|
||
{
|
||
public class GameModuleLoaderTests
|
||
{
|
||
// ALC 加载 + 跨界类型隔离 + 广播已由 RpsModuleLoadTests 覆盖;此处只保留缺版本的失败路径。
|
||
[Fact]
|
||
public void Load_MissingVersion_Throws()
|
||
{
|
||
Assert.ThrowsAny<System.Exception>(
|
||
() => new GameModuleLoader().Load(TestGames.Root, "rps", 99));
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: 编译并运行该文件的测试**
|
||
|
||
Run: `dotnet test Server/Server.sln --filter "FullyQualifiedName~GameModuleLoaderTests"`
|
||
Expected: PASS(1 个测试通过),无编译错误。
|
||
|
||
---
|
||
|
||
## Task 4: AlcUnloadTests 改用 rps
|
||
|
||
**Files:**
|
||
- Modify: `Server/Server.Host.Tests/AlcUnloadTests.cs`(整文件替换)
|
||
|
||
- [ ] **Step 1: 用以下内容整体替换 `AlcUnloadTests.cs`**
|
||
|
||
```csharp
|
||
using System;
|
||
using System.Runtime.CompilerServices;
|
||
using Xunit;
|
||
using XWorld.Framework;
|
||
using XWorld.Server.Host;
|
||
|
||
namespace XWorld.Server.Host.Tests
|
||
{
|
||
public class AlcUnloadTests
|
||
{
|
||
private sealed class NullOutput : IRoomOutput
|
||
{
|
||
public void Broadcast(NetMessage m) { }
|
||
public void SendTo(int playerId, NetMessage m) { }
|
||
}
|
||
|
||
// 在独立方法里加载/运行/卸载,确保局部强引用随方法返回而消失
|
||
[MethodImpl(MethodImplOptions.NoInlining)]
|
||
private static (WeakReference alc, WeakReference asm) LoadRunAndUnload()
|
||
{
|
||
var module = new GameModuleLoader().Load(TestGames.Root, "rps", 1);
|
||
IGameServerRoom room = module.CreateRoom();
|
||
room.OnRoomStart(
|
||
new[]
|
||
{
|
||
new PlayerInfo { PlayerId = 1, Name = "H", IsAI = false },
|
||
new PlayerInfo { PlayerId = -1, Name = "AI", IsAI = true },
|
||
},
|
||
new RoomCtx(new DeterministicRandom(1), new ManualClock(), new ConsoleLogger(),
|
||
new InMemoryStorage(), new NullOutput(), () => { }));
|
||
room.OnTick(1f); // dt=1 < 5s,仍在 Choosing,仅广播一次快照
|
||
var asmWeak = new WeakReference(room.GetType().Assembly); // 游戏 ALC 内加载的程序集
|
||
room = null;
|
||
return (module.Unload(), asmWeak);
|
||
}
|
||
|
||
[Fact]
|
||
public void GameModule_Unload_ReclaimsMemory()
|
||
{
|
||
var (alcWeak, asmWeak) = LoadRunAndUnload();
|
||
for (int i = 0; i < 12 && (alcWeak.IsAlive || asmWeak.IsAlive); i++)
|
||
{
|
||
GC.Collect();
|
||
GC.WaitForPendingFinalizers();
|
||
}
|
||
Assert.False(alcWeak.IsAlive, "游戏模块 ALC 未被回收 —— 存在泄漏引用");
|
||
Assert.False(asmWeak.IsAlive, "游戏程序集未被回收 —— 存在泄漏引用");
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: 运行该测试**
|
||
|
||
Run: `dotnet test Server/Server.sln --filter "FullyQualifiedName~AlcUnloadTests"`
|
||
Expected: PASS。
|
||
|
||
---
|
||
|
||
## Task 5: GameModuleRegistryTests 改用 rps 1/2
|
||
|
||
`rps/2` 与 `rps/1` 同 IL、版本号不同,正好驱动 refcount/淘汰/卸载逻辑(这些只看版本号,不看 IL 差异)。
|
||
|
||
**Files:**
|
||
- Modify: `Server/Server.Host.Tests/GameModuleRegistryTests.cs`(整文件替换)
|
||
|
||
- [ ] **Step 1: 用以下内容整体替换 `GameModuleRegistryTests.cs`**
|
||
|
||
```csharp
|
||
using Xunit;
|
||
using XWorld.Server.Host;
|
||
|
||
namespace XWorld.Server.Host.Tests
|
||
{
|
||
public class GameModuleRegistryTests
|
||
{
|
||
private static GameModuleRegistry NewRegistry()
|
||
=> new GameModuleRegistry(new GameModuleLoader(), TestGames.Root);
|
||
|
||
[Fact]
|
||
public void Acquire_SameVersionTwice_LoadsOnce_RefCounts()
|
||
{
|
||
var reg = NewRegistry();
|
||
var a = reg.Acquire("rps", 1);
|
||
var b = reg.Acquire("rps", 1);
|
||
Assert.Same(a, b); // 复用同一模块
|
||
Assert.Equal(2, reg.RefCount("rps", 1));
|
||
}
|
||
|
||
[Fact]
|
||
public void Release_ToZero_WithoutSupersede_KeepsLoaded()
|
||
{
|
||
var reg = NewRegistry();
|
||
var m = reg.Acquire("rps", 1);
|
||
reg.Release(m);
|
||
Assert.Equal(0, reg.RefCount("rps", 1));
|
||
Assert.True(reg.IsLoaded("rps", 1)); // 未被淘汰则保留以复用
|
||
}
|
||
|
||
[Fact]
|
||
public void NewVersion_Supersedes_Old_And_OldUnloadsWhenRefZero()
|
||
{
|
||
var reg = NewRegistry();
|
||
var v1 = reg.Acquire("rps", 1); // v1 引用=1
|
||
var v2 = reg.Acquire("rps", 2); // 取 v2 → v1 被标记淘汰
|
||
Assert.True(reg.IsLoaded("rps", 1));
|
||
Assert.True(reg.IsLoaded("rps", 2));
|
||
|
||
reg.Release(v1); // v1 引用归零且被淘汰 → 卸载并移除
|
||
Assert.False(reg.IsLoaded("rps", 1));
|
||
Assert.True(reg.IsLoaded("rps", 2)); // v2 仍在
|
||
}
|
||
|
||
[Fact]
|
||
public void Acquire_FailedNewVersionLoad_DoesNotSupersedeExisting()
|
||
{
|
||
var reg = NewRegistry();
|
||
var v1 = reg.Acquire("rps", 1);
|
||
Assert.ThrowsAny<System.Exception>(() => reg.Acquire("rps", 99)); // v99 不存在,加载抛出
|
||
reg.Release(v1); // v1 引用归零
|
||
Assert.True(reg.IsLoaded("rps", 1)); // 未被错误淘汰,仍保留
|
||
}
|
||
|
||
[Fact]
|
||
public void Release_MoreThanAcquired_Throws()
|
||
{
|
||
var reg = NewRegistry();
|
||
var m = reg.Acquire("rps", 1);
|
||
reg.Release(m);
|
||
Assert.Throws<System.InvalidOperationException>(() => reg.Release(m));
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: 运行该测试**
|
||
|
||
Run: `dotnet test Server/Server.sln --filter "FullyQualifiedName~GameModuleRegistryTests"`
|
||
Expected: PASS(5 个测试)。
|
||
|
||
---
|
||
|
||
## Task 6: GameManifestTests 的 Valid 常量改 rps
|
||
|
||
**Files:**
|
||
- Modify: `Server/Server.Host.Tests/GameManifestTests.cs:9-30`
|
||
|
||
- [ ] **Step 1: 替换 `Valid` 常量(原 9-17 行)为:**
|
||
|
||
```csharp
|
||
private const string Valid = @"{
|
||
""gameId"": ""rps"",
|
||
""version"": 2,
|
||
""serverAssembly"": ""RPS.Server.dll"",
|
||
""serverEntryType"": ""RPS.Server.RpsServerRoom"",
|
||
""minFrameworkVersion"": 1,
|
||
""playerCount"": 2,
|
||
""tickRateHz"": 10
|
||
}";
|
||
```
|
||
|
||
- [ ] **Step 2: 替换 `Parse_Valid_ReadsAllFields` 方法体(原 20-30 行)为:**
|
||
|
||
```csharp
|
||
public void Parse_Valid_ReadsAllFields()
|
||
{
|
||
var m = GameManifest.Parse(Valid);
|
||
Assert.Equal("rps", m.GameId);
|
||
Assert.Equal(2, m.Version);
|
||
Assert.Equal("RPS.Server.dll", m.ServerAssembly);
|
||
Assert.Equal("RPS.Server.RpsServerRoom", m.ServerEntryType);
|
||
Assert.Equal(1, m.MinFrameworkVersion);
|
||
Assert.Equal(2, m.PlayerCount);
|
||
Assert.Equal(10, m.TickRateHz);
|
||
}
|
||
```
|
||
|
||
(其余测试 `Parse_CaseInsensitive_FieldNames`、`Parse_Invalid_Throws` 与游戏无关,不改。)
|
||
|
||
- [ ] **Step 3: 运行该测试**
|
||
|
||
Run: `dotnet test Server/Server.sln --filter "FullyQualifiedName~GameManifestTests"`
|
||
Expected: PASS。
|
||
|
||
---
|
||
|
||
## Task 7: 重写 RoomHostTests 为 rps 语义
|
||
|
||
Hello 用"计数器、第 3 tick 结束、单人"建模;RPS 用"回合制、60s 结束、2 人(含 AI)、出招 opcode=1"。用 `RpsLogic.Decode` 读快照(本项目已引用 `RPS.Core`)。
|
||
|
||
**Files:**
|
||
- Modify: `Server/Server.Host.Tests/RoomHostTests.cs`(整文件替换)
|
||
|
||
- [ ] **Step 1: 用以下内容整体替换 `RoomHostTests.cs`**
|
||
|
||
```csharp
|
||
using System.Collections.Generic;
|
||
using Xunit;
|
||
using XWorld.Framework;
|
||
using XWorld.Framework.Protocol;
|
||
using XWorld.Server.Host;
|
||
using RPS.Core;
|
||
|
||
namespace XWorld.Server.Host.Tests
|
||
{
|
||
public class RoomHostTests
|
||
{
|
||
private sealed class Capture : IRoomOutput
|
||
{
|
||
public List<NetMessage> Broadcasts = new List<NetMessage>();
|
||
public void Broadcast(NetMessage m) => Broadcasts.Add(m);
|
||
public void SendTo(int playerId, NetMessage m) { }
|
||
}
|
||
|
||
// 1 真人(seat 0) + 1 AI(seat 1)
|
||
private static readonly IReadOnlyList<PlayerInfo> Players = new[]
|
||
{
|
||
new PlayerInfo { PlayerId = 1, Name = "H", IsAI = false },
|
||
new PlayerInfo { PlayerId = -1, Name = "AI", IsAI = true },
|
||
};
|
||
|
||
private static RpsState LastState(Capture cap)
|
||
=> new RpsLogic().Decode(cap.Broadcasts[cap.Broadcasts.Count - 1].Payload);
|
||
|
||
private static RoomHost NewHost()
|
||
=> new RoomHost(new GameModuleRegistry(new GameModuleLoader(), TestGames.Root), new ConsoleLogger());
|
||
|
||
private static RoomConfig Cfg()
|
||
=> new RoomConfig { GameId = "rps", Version = 1, Seed = 1, PlayerCount = 2 };
|
||
|
||
[Fact]
|
||
public void CreateRoom_TickToEnd_ReleasesModule()
|
||
{
|
||
var host = NewHost();
|
||
var cap = new Capture();
|
||
|
||
var room = host.CreateRoom("A", "rps", 1, Cfg(), Players, cap);
|
||
Assert.Equal(1, host.ActiveRoomCount);
|
||
|
||
// 大 dt 推进:60s 对局在数个大 tick 内结束
|
||
for (int i = 0; i < 10 && host.ActiveRoomCount > 0; i++) host.TickAll(10f);
|
||
|
||
Assert.Equal(0, host.ActiveRoomCount);
|
||
Assert.False(room.IsActive);
|
||
Assert.Equal(RpsPhase.Finished, LastState(cap).Phase);
|
||
}
|
||
|
||
[Fact]
|
||
public void Deliver_RoutesChoice_AffectsState()
|
||
{
|
||
var host = NewHost();
|
||
var cap = new Capture();
|
||
host.CreateRoom("A", "rps", 1, Cfg(), Players, cap);
|
||
|
||
// seat 0(玩家 1)出 Rock:opcode=1(ChoiceOpcode),payload=单字节 choice
|
||
var w = new PacketWriter();
|
||
w.WriteByte((byte)Choice.Rock);
|
||
host.DeliverTo("A", 1, new NetMessage(1, w.ToArray()));
|
||
host.TickAll(0.1f); // 小 dt,仍在 Choosing,广播快照
|
||
|
||
Assert.Equal(Choice.Rock, LastState(cap).Choices[0]);
|
||
}
|
||
|
||
[Fact]
|
||
public void CreateRoom_DuplicateRoomId_Throws()
|
||
{
|
||
var host = NewHost();
|
||
host.CreateRoom("dup", "rps", 1, Cfg(), Players, new Capture());
|
||
Assert.Throws<System.InvalidOperationException>(
|
||
() => host.CreateRoom("dup", "rps", 1, Cfg(), Players, new Capture()));
|
||
}
|
||
|
||
[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<string>();
|
||
for (int i = 0; i < 10 && !ended.Contains("E"); i++)
|
||
ended.AddRange(host.TickAll(10f));
|
||
Assert.Contains("E", ended); // 累计超过 60s 后该 tick 返回结束房间 id
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: 运行该测试**
|
||
|
||
Run: `dotnet test Server/Server.sln --filter "FullyQualifiedName~RoomHostTests"`
|
||
Expected: PASS(4 个测试)。
|
||
|
||
---
|
||
|
||
## Task 8: ServerLoopTests 去冗余 + 重连/在房重复匹配改 rps
|
||
|
||
删除 hello 专属的 `Join_RepliesMatchFound_...` 与 `GameInput_RoutedToRoom_...`(已被 `ServerLoopMatchmakingTests` 覆盖);保留与游戏无关的心跳/过期/无房间帧用例;把重连、在房重复匹配改为 rps 2 人;`MatchRequest_WithNoSession` 的 gameId 改 rps。
|
||
|
||
**Files:**
|
||
- Modify: `Server/Gateway.Tests/ServerLoopTests.cs`(整文件替换)
|
||
|
||
- [ ] **Step 1: 用以下内容整体替换 `ServerLoopTests.cs`**
|
||
|
||
```csharp
|
||
using System.Collections.Generic;
|
||
using Xunit;
|
||
using XWorld.Framework;
|
||
using XWorld.Framework.Protocol;
|
||
using XWorld.Server.Host;
|
||
using XWorld.Server.Gateway;
|
||
|
||
namespace XWorld.Server.Gateway.Tests
|
||
{
|
||
public class ServerLoopTests
|
||
{
|
||
private static ServerLoop NewLoop(long reconnectWindow = 100)
|
||
=> new ServerLoop(TestGames.Root, new ConsoleLogger("[loop]"), reconnectWindow);
|
||
|
||
private static byte[] JoinFrame(string gameId, int version)
|
||
=> FrameCodec.Encode(new Frame(Channel.Framework, (ushort)FrameworkOpcode.MatchRequest,
|
||
new MatchRequestMsg { GameId = gameId, Version = version }.Encode()));
|
||
|
||
private static List<Frame> Frames(FakeConnection c)
|
||
{
|
||
var list = new List<Frame>();
|
||
foreach (var bytes in c.Sent) list.Add(FrameCodec.Decode(bytes));
|
||
return list;
|
||
}
|
||
|
||
[Fact]
|
||
public void Heartbeat_IsEchoed()
|
||
{
|
||
var loop = NewLoop();
|
||
var c = new FakeConnection(1);
|
||
loop.Submit(new ConnectCommand { PlayerId = 1, Connection = c });
|
||
var hb = new HeartbeatMsg { ClientTimeMs = 123456789L };
|
||
loop.Submit(new FrameCommand { PlayerId = 1,
|
||
Frame = new Frame(Channel.Framework, (ushort)FrameworkOpcode.Heartbeat, hb.Encode()) });
|
||
|
||
loop.DrainAndTick(0f);
|
||
|
||
var echo = Frames(c).Find(f => f.Channel == Channel.Framework && f.Opcode == (ushort)FrameworkOpcode.Heartbeat);
|
||
Assert.NotEqual(default, echo);
|
||
Assert.Equal(123456789L, HeartbeatMsg.Decode(echo.Payload).ClientTimeMs);
|
||
}
|
||
|
||
[Fact]
|
||
public void Reconnect_ReroutesRoomOutputToNewConnection()
|
||
{
|
||
var loop = NewLoop(reconnectWindow: 100);
|
||
var c1 = new FakeConnection(1);
|
||
var c2 = new FakeConnection(2);
|
||
loop.Submit(new ConnectCommand { PlayerId = 1, Connection = c1 });
|
||
loop.Submit(new ConnectCommand { PlayerId = 2, Connection = c2 });
|
||
loop.Submit(new FrameCommand { PlayerId = 1, Frame = FrameCodec.Decode(JoinFrame("rps", 1)) });
|
||
loop.Submit(new FrameCommand { PlayerId = 2, Frame = FrameCodec.Decode(JoinFrame("rps", 1)) });
|
||
loop.DrainAndTick(1f); // 2 人成团:c1/c2 收到 MatchFound + 快照
|
||
Assert.True(c1.Sent.Count >= 2);
|
||
|
||
// pid=1 断线后重连到新连接 c1b
|
||
loop.Submit(new DisconnectCommand { PlayerId = 1, Connection = c1 });
|
||
var c1b = new FakeConnection(1);
|
||
loop.Submit(new ConnectCommand { PlayerId = 1, Connection = c1b });
|
||
loop.DrainAndTick(1f); // 房间仍在跑(60s 未到):下一快照应发往 c1b
|
||
|
||
Assert.Contains(Frames(c1b), f => f.Channel == Channel.Game);
|
||
}
|
||
|
||
[Fact]
|
||
public void ExpiredSession_IsSwept()
|
||
{
|
||
var loop = NewLoop(reconnectWindow: 2);
|
||
var c = new FakeConnection(1);
|
||
loop.Submit(new ConnectCommand { PlayerId = 1, Connection = c });
|
||
loop.DrainAndTick(1f); // _tick: 0→1
|
||
loop.Submit(new DisconnectCommand { PlayerId = 1, Connection = c });
|
||
loop.DrainAndTick(1f); // 排空时 _tick=1 → DisconnectedAtTick=1;随后 _tick→2
|
||
loop.DrainAndTick(1f); // _tick→3:3-1=2,不 >2,保留
|
||
loop.DrainAndTick(1f); // _tick→4:4-1=3 >2 → 过期清除
|
||
Assert.False(loop.HasSession(1));
|
||
}
|
||
|
||
[Fact]
|
||
public void DuplicateMatchRequest_WhileInRoom_IsRejectedWithError()
|
||
{
|
||
var loop = NewLoop();
|
||
var c1 = new FakeConnection(1);
|
||
var c2 = new FakeConnection(2);
|
||
loop.Submit(new ConnectCommand { PlayerId = 1, Connection = c1 });
|
||
loop.Submit(new ConnectCommand { PlayerId = 2, Connection = c2 });
|
||
loop.Submit(new FrameCommand { PlayerId = 1, Frame = FrameCodec.Decode(JoinFrame("rps", 1)) });
|
||
loop.Submit(new FrameCommand { PlayerId = 2, Frame = FrameCodec.Decode(JoinFrame("rps", 1)) });
|
||
loop.DrainAndTick(1f); // 2 人入房
|
||
|
||
// 已在房间内再次 join → 回 Error
|
||
loop.Submit(new FrameCommand { PlayerId = 1, Frame = FrameCodec.Decode(JoinFrame("rps", 1)) });
|
||
loop.DrainAndTick(1f);
|
||
|
||
Assert.Contains(Frames(c1), f => f.Channel == Channel.Framework && f.Opcode == (ushort)FrameworkOpcode.Error);
|
||
}
|
||
|
||
[Fact]
|
||
public void GameFrame_WithNoRoom_IsIgnoredSafely()
|
||
{
|
||
var loop = NewLoop();
|
||
var c = new FakeConnection(1);
|
||
loop.Submit(new ConnectCommand { PlayerId = 1, Connection = c });
|
||
var w = new PacketWriter(); w.WriteVarInt(3);
|
||
loop.Submit(new FrameCommand { PlayerId = 1, Frame = new Frame(Channel.Game, 0, w.ToArray()) });
|
||
|
||
var ex = Record.Exception(() => loop.DrainAndTick(1f));
|
||
Assert.Null(ex); // 无房间的游戏帧被安全忽略,不抛
|
||
Assert.Empty(c.Sent); // 无任何输出
|
||
}
|
||
|
||
[Fact]
|
||
public void MatchRequest_WithNoSession_IsIgnoredSafely()
|
||
{
|
||
var loop = NewLoop();
|
||
// 未发 ConnectCommand,直接来一个 join 帧
|
||
loop.Submit(new FrameCommand { PlayerId = 99, Frame = FrameCodec.Decode(JoinFrame("rps", 1)) });
|
||
var ex = Record.Exception(() => loop.DrainAndTick(1f));
|
||
Assert.Null(ex);
|
||
Assert.False(loop.HasSession(99));
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: 运行该测试**
|
||
|
||
Run: `dotnet test Server/Server.sln --filter "FullyQualifiedName~ServerLoopTests"`
|
||
Expected: PASS(6 个测试)。注意 filter `~ServerLoopTests` 也会匹配 `ServerLoopMatchmakingTests`/`ServerLoopSignatureEnforcementTests`,它们应一并通过。
|
||
|
||
---
|
||
|
||
## Task 9: GatewayEndToEndTests 去冗余 + 重连改 rps(AI 兜底)
|
||
|
||
删除 hello e2e(已被 `RpsEndToEndTests` 覆盖);心跳用例与游戏无关保留;重连用例改为 rps 单人 + AI 兜底,小 `logicalDt` 让 60s 对局慢跑以留出重连时间。
|
||
|
||
**Files:**
|
||
- Modify: `Server/Gateway.Tests/GatewayEndToEndTests.cs`(整文件替换)
|
||
- Modify: `Server/Gateway.Tests/Gateway.Tests.csproj:27`(注释去 Hello 字样)
|
||
|
||
- [ ] **Step 0: 更新 `Gateway.Tests.csproj` 第 27 行注释**
|
||
|
||
把:
|
||
```xml
|
||
<!-- 复用 2a 已构建的 Hello 夹具(由 build-test-games.sh 生成到 Server.Host.Tests/TestGames) -->
|
||
```
|
||
改为:
|
||
```xml
|
||
<!-- 复用 build-test-games.sh 生成到 Server.Host.Tests/TestGames 的 rps 夹具 -->
|
||
```
|
||
|
||
- [ ] **Step 1: 用以下内容整体替换 `GatewayEndToEndTests.cs`**
|
||
|
||
```csharp
|
||
using System.Threading.Tasks;
|
||
using Xunit;
|
||
using XWorld.Framework.Protocol;
|
||
using XWorld.Server.Gateway;
|
||
|
||
namespace XWorld.Server.Gateway.Tests
|
||
{
|
||
public class GatewayEndToEndTests
|
||
{
|
||
private static Frame JoinRps()
|
||
=> new Frame(Channel.Framework, (ushort)FrameworkOpcode.MatchRequest,
|
||
new MatchRequestMsg { GameId = "rps", Version = 1 }.Encode());
|
||
|
||
[Fact]
|
||
public async Task Heartbeat_IsEchoedBack()
|
||
{
|
||
var host = new GameServerHost();
|
||
await host.StartAsync(TestGames.Root, tickIntervalMs: 20);
|
||
try
|
||
{
|
||
using var client = new WsTestClient();
|
||
await client.ConnectAsync(host.WsBaseUrl, pid: 7);
|
||
await client.SendFrameAsync(new Frame(Channel.Framework, (ushort)FrameworkOpcode.Heartbeat,
|
||
new HeartbeatMsg { ClientTimeMs = 999L }.Encode()));
|
||
|
||
Frame? echo = null;
|
||
for (int i = 0; i < 10; i++)
|
||
{
|
||
var f = await client.ReceiveFrameAsync(5000);
|
||
if (f.Channel == Channel.Framework && f.Opcode == (ushort)FrameworkOpcode.Heartbeat) { echo = f; break; }
|
||
}
|
||
Assert.True(echo.HasValue, "未收到心跳回显");
|
||
Assert.Equal(999L, HeartbeatMsg.Decode(echo.Value.Payload).ClientTimeMs);
|
||
await client.CloseAsync();
|
||
}
|
||
finally { await host.StopAsync(); }
|
||
}
|
||
|
||
[Fact]
|
||
public async Task Reconnect_ContinuesReceivingFromSameRoom()
|
||
{
|
||
var host = new GameServerHost();
|
||
// matchTimeoutTicks=3 → 单人后 AI 兜底成团;logicalDt=0.2 让 60s 对局慢跑;大重连窗口
|
||
await host.StartAsync(TestGames.Root, tickIntervalMs: 200,
|
||
reconnectWindowTicks: 100, matchTimeoutTicks: 3, logicalDt: 0.2f);
|
||
try
|
||
{
|
||
var client1 = new WsTestClient();
|
||
await client1.ConnectAsync(host.WsBaseUrl, pid: 42);
|
||
await client1.SendFrameAsync(JoinRps());
|
||
|
||
// 收到 MatchFound 后的第一个游戏快照
|
||
bool gotSnapshot = false;
|
||
for (int i = 0; i < 15 && !gotSnapshot; i++)
|
||
{
|
||
var f = await client1.ReceiveFrameAsync(5000);
|
||
if (f.Channel == Channel.Game) gotSnapshot = true;
|
||
}
|
||
Assert.True(gotSnapshot, "重连前应先收到一个快照");
|
||
|
||
// 断开
|
||
await client1.CloseAsync();
|
||
client1.Dispose();
|
||
|
||
// 用同 pid 重连
|
||
using var client2 = new WsTestClient();
|
||
await client2.ConnectAsync(host.WsBaseUrl, pid: 42);
|
||
|
||
// 房间仍在跑,下一个房间输出应发到 client2
|
||
bool gotAfterReconnect = false;
|
||
for (int i = 0; i < 15 && !gotAfterReconnect; i++)
|
||
{
|
||
var f = await client2.ReceiveFrameAsync(5000);
|
||
if (f.Channel == Channel.Game) gotAfterReconnect = true;
|
||
else if (f.Channel == Channel.Framework && f.Opcode == (ushort)FrameworkOpcode.RoomEnd) gotAfterReconnect = true;
|
||
}
|
||
Assert.True(gotAfterReconnect, "重连后应继续收到房间输出(快照或 RoomEnd)");
|
||
await client2.CloseAsync();
|
||
}
|
||
finally { await host.StopAsync(); }
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: 运行该测试**
|
||
|
||
Run: `dotnet test Server/Server.sln --filter "FullyQualifiedName~GatewayEndToEndTests"`
|
||
Expected: PASS(2 个测试)。
|
||
|
||
---
|
||
|
||
## Task 10: MatchmakerTests 的 playerCount=1 用例去 hello 标签
|
||
|
||
`Matchmaker` 是纯队列逻辑,不加载具体游戏,gameId 仅作分桶标签,改成非 hello 字符串即可。
|
||
|
||
**Files:**
|
||
- Modify: `Server/Gateway.Tests/MatchmakerTests.cs:63-75`
|
||
|
||
- [ ] **Step 1: 替换 `PlayerCount1_SingleEnqueue_PollImmediately_ReturnsMatch` 方法(原 63-75 行)为:**
|
||
|
||
```csharp
|
||
[Fact]
|
||
public void PlayerCount1_SingleEnqueue_PollImmediately_ReturnsMatch()
|
||
{
|
||
// playerCount=1 应立即成团(Matchmaker 仅按 playerCount 分桶,不加载具体游戏)
|
||
var mm = new Matchmaker(timeoutTicks: 100);
|
||
mm.Enqueue(pid: 5, gameId: "solo1p", version: 1, playerCount: 1, tick: 0);
|
||
|
||
var matches = mm.Poll(currentTick: 0);
|
||
|
||
Assert.Single(matches);
|
||
Assert.Single(matches[0].Seats);
|
||
Assert.False(matches[0].Seats[0].IsAi);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: 运行该测试**
|
||
|
||
Run: `dotnet test Server/Server.sln --filter "FullyQualifiedName~MatchmakerTests"`
|
||
Expected: PASS(5 个测试)。
|
||
|
||
---
|
||
|
||
## Task 10b: 修正受 rps/2 影响的 lobby 发现测试(执行期发现的计划缺口)
|
||
|
||
引入 `rps/2` 后,`ServerLoop.BuildGameList()` 按"每 gameId 最新版本"发现,大厅改列 rps v2,导致既有测试 `ServerLoopMatchmakingTests.Lobby_GameList_AndRandomMatch_AssignsDiscoverableGame` 的 `Version == 1` 断言失败。版本号非该测试重点,改为版本无关 + 断言"随机匹配分配大厅所列版本"。
|
||
|
||
**Files:**
|
||
- Modify: `Server/Gateway.Tests/ServerLoopMatchmakingTests.cs`(Lobby 测试两处断言)
|
||
|
||
- [ ] **Step 1:** 把 `Assert.Contains(list.Games, g => g.GameId == "rps" && g.Version == 1);` 改为版本无关并捕获 rps 版本:
|
||
```csharp
|
||
// 两个 rps 夹具(v1/v2,同 IL)并存时,大厅只列出最新版本;具体版本号非本测试重点
|
||
Assert.Contains(list.Games, g => g.GameId == "rps");
|
||
int rpsVersion = 0;
|
||
foreach (var g in list.Games) if (g.GameId == "rps") rpsVersion = g.Version;
|
||
```
|
||
- [ ] **Step 2:** 把 `Assert.Equal(1, assigned.Version);` 改为 `Assert.Equal(rpsVersion, assigned.Version);`
|
||
- [ ] **Step 3:** Run `dotnet test Server/Gateway.Tests/Gateway.Tests.csproj` → 全绿。
|
||
|
||
---
|
||
|
||
## Task 11: 全量服务端回归(删 Hello 后第一道总闸)
|
||
|
||
**Files:** 无(仅运行)
|
||
|
||
- [ ] **Step 1: 运行整个解决方案测试**
|
||
|
||
Run: `dotnet test Server/Server.sln`
|
||
Expected: 全部 PASS,0 失败。失败项必为某处仍引用 hello 夹具或断言未适配——回到对应 Task 修正后再跑。
|
||
|
||
---
|
||
|
||
## Task 12: 新增 RPS C# 客户端薄层(对标 HelloFlowGameClient)
|
||
|
||
源码唯一位置在 Assets 下(Unity/HybridCLR 编译它);另建一个 `RPS.Client.csproj`(放 `Client/HotUpdateGames/`,与 HelloFlow.Client 同级)用 `<Compile Include>` 链接同一批源做 csc 验证。客户端按现有 `RpsState` 字段(座位制,无 net id)呈现,不扩协议。
|
||
|
||
**Files:**
|
||
- Create: `Client/Assets/MiniGames/RockPaperScissors/scripts/Client/RpsGameClient.cs`
|
||
- Create: `Client/HotUpdateGames/RPS.Client/RPS.Client.csproj`
|
||
|
||
- [ ] **Step 1: 创建 `Client/Assets/MiniGames/RockPaperScissors/scripts/Client/RpsGameClient.cs`**
|
||
|
||
```csharp
|
||
using RPS.Core;
|
||
using UnityEngine;
|
||
using XWorld.Framework;
|
||
using XWorld.Framework.Protocol;
|
||
|
||
namespace RPS.Client
|
||
{
|
||
// RPS 客户端薄层:解码服务端快照、渲染 IMGUI、在 Choosing 阶段提交一次出招。
|
||
// 协议:服务端广播 SnapshotOpcode=1;客户端出招 ChoiceOpcode=1,payload=单字节 Choice。
|
||
public sealed class RpsGameClient : IGameClient
|
||
{
|
||
private const ushort SnapshotOpcode = 1;
|
||
private const ushort ChoiceOpcode = 1;
|
||
|
||
private readonly RpsLogic _logic = new RpsLogic();
|
||
private IGameClientCtx _ctx;
|
||
private GameObject _viewObject;
|
||
private RpsView _view;
|
||
|
||
public void OnEnter(IGameClientCtx ctx)
|
||
{
|
||
_ctx = ctx;
|
||
_viewObject = new GameObject("RpsView");
|
||
Object.DontDestroyOnLoad(_viewObject);
|
||
_view = _viewObject.AddComponent<RpsView>();
|
||
_view.Client = this;
|
||
_view.Status = "waiting for match...";
|
||
_ctx.Logger.Info("rps client entered");
|
||
}
|
||
|
||
public void OnNetMessage(NetMessage msg)
|
||
{
|
||
if (msg.Opcode != SnapshotOpcode) return;
|
||
var state = _logic.Decode(msg.Payload);
|
||
if (_view == null) return;
|
||
|
||
// 进入新一回合的 Choosing 时解锁本地出招
|
||
if (state.Phase == RpsPhase.Choosing && _view.State != null
|
||
&& _view.State.Round != state.Round)
|
||
_view.LocalChoice = Choice.None;
|
||
|
||
_view.State = state;
|
||
if (state.Phase == RpsPhase.Finished)
|
||
_view.Status = state.Winner == 2 ? "finished: draw"
|
||
: "finished: seat " + state.Winner + " wins";
|
||
else
|
||
_view.Status = "round " + state.Round + " / " + state.Phase;
|
||
}
|
||
|
||
public void OnUpdate(float dt)
|
||
{
|
||
if (_view != null) _view.Elapsed += dt;
|
||
}
|
||
|
||
public void OnExit()
|
||
{
|
||
_ctx?.Logger.Info("rps client exited");
|
||
if (_viewObject != null) Object.Destroy(_viewObject);
|
||
_viewObject = null;
|
||
_view = null;
|
||
_ctx = null;
|
||
}
|
||
|
||
// 由视图按钮调用:Choosing 阶段提交一次出招(本回合只发一次)
|
||
public void SubmitChoice(Choice choice)
|
||
{
|
||
if (_ctx == null || _view == null || _view.State == null) return;
|
||
if (_view.State.Phase != RpsPhase.Choosing) return;
|
||
if (_view.LocalChoice != Choice.None) return;
|
||
_view.LocalChoice = choice;
|
||
var w = new PacketWriter();
|
||
w.WriteByte((byte)choice);
|
||
_ctx.Send(new NetMessage(ChoiceOpcode, w.ToArray()));
|
||
_view.Status = "locked: " + choice;
|
||
}
|
||
}
|
||
|
||
public sealed class RpsView : MonoBehaviour
|
||
{
|
||
public RpsGameClient Client;
|
||
public string Status;
|
||
public RpsState State;
|
||
public Choice LocalChoice = Choice.None;
|
||
public float Elapsed;
|
||
|
||
private static string ChoiceName(Choice c)
|
||
=> c == Choice.Rock ? "Rock"
|
||
: c == Choice.Paper ? "Paper"
|
||
: c == Choice.Scissors ? "Scissors" : "-";
|
||
|
||
private void OnGUI()
|
||
{
|
||
GUILayout.BeginArea(new Rect(16, 16, 460, 320), GUI.skin.box);
|
||
GUILayout.Label("Rock-Paper-Scissors (Hot DLL)");
|
||
GUILayout.Label("Status: " + Status);
|
||
GUILayout.Label("Elapsed: " + Elapsed.ToString("0.0") + "s");
|
||
|
||
if (State == null)
|
||
{
|
||
GUILayout.Label("No snapshot yet");
|
||
}
|
||
else
|
||
{
|
||
GUILayout.Label("Round: " + State.Round + " Phase: " + State.Phase);
|
||
GUILayout.Label("Score: seat0 " + State.Scores[0] + " : " + State.Scores[1] + " seat1");
|
||
GUILayout.Label("This round: seat0=" + ChoiceName(State.Choices[0])
|
||
+ " seat1=" + ChoiceName(State.Choices[1]));
|
||
|
||
bool canChoose = State.Phase == RpsPhase.Choosing && LocalChoice == Choice.None;
|
||
GUI.enabled = canChoose;
|
||
GUILayout.BeginHorizontal();
|
||
if (GUILayout.Button("Rock")) Client?.SubmitChoice(Choice.Rock);
|
||
if (GUILayout.Button("Paper")) Client?.SubmitChoice(Choice.Paper);
|
||
if (GUILayout.Button("Scissors")) Client?.SubmitChoice(Choice.Scissors);
|
||
GUILayout.EndHorizontal();
|
||
GUI.enabled = true;
|
||
|
||
if (LocalChoice != Choice.None)
|
||
GUILayout.Label("Your choice: " + ChoiceName(LocalChoice));
|
||
if (State.Phase == RpsPhase.Finished)
|
||
GUILayout.Label("Winner: " + (State.Winner == 2 ? "draw" : "seat " + State.Winner));
|
||
}
|
||
|
||
GUILayout.EndArea();
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: 创建 `Client/HotUpdateGames/RPS.Client/RPS.Client.csproj`**
|
||
|
||
```xml
|
||
<Project Sdk="Microsoft.NET.Sdk">
|
||
|
||
<PropertyGroup>
|
||
<TargetFramework>netstandard2.1</TargetFramework>
|
||
<LangVersion>9.0</LangVersion>
|
||
<Nullable>disable</Nullable>
|
||
<ImplicitUsings>disable</ImplicitUsings>
|
||
<AssemblyName>RPS.Client</AssemblyName>
|
||
<RootNamespace>RPS.Client</RootNamespace>
|
||
<EnableDefaultCompileItems>false</EnableDefaultCompileItems>
|
||
<UnityEditorPath Condition="'$(UnityEditorPath)' == ''">D:\Program Files\Unity 2022.3.62f2c1\Editor</UnityEditorPath>
|
||
</PropertyGroup>
|
||
|
||
<ItemGroup>
|
||
<!-- 客户端薄层源码唯一位置在 Assets 下(Unity/HybridCLR 编译它);本工程仅链接同一批源做 csc 验证 -->
|
||
<Compile Include="..\..\Assets\MiniGames\RockPaperScissors\scripts\Client\**\*.cs" />
|
||
</ItemGroup>
|
||
|
||
<ItemGroup>
|
||
<ProjectReference Include="..\..\..\Server\Framework.Shared\Framework.Shared.csproj">
|
||
<Private>false</Private>
|
||
</ProjectReference>
|
||
<ProjectReference Include="..\..\..\Server\games-src\RPS\RPS.Core\RPS.Core.csproj">
|
||
<Private>false</Private>
|
||
</ProjectReference>
|
||
</ItemGroup>
|
||
|
||
<ItemGroup>
|
||
<Reference Include="UnityEngine">
|
||
<HintPath>$(UnityEditorPath)\Data\Managed\UnityEngine\UnityEngine.dll</HintPath>
|
||
<Private>false</Private>
|
||
</Reference>
|
||
<Reference Include="UnityEngine.CoreModule">
|
||
<HintPath>$(UnityEditorPath)\Data\Managed\UnityEngine\UnityEngine.CoreModule.dll</HintPath>
|
||
<Private>false</Private>
|
||
</Reference>
|
||
<Reference Include="UnityEngine.IMGUIModule">
|
||
<HintPath>$(UnityEditorPath)\Data\Managed\UnityEngine\UnityEngine.IMGUIModule.dll</HintPath>
|
||
<Private>false</Private>
|
||
</Reference>
|
||
</ItemGroup>
|
||
|
||
</Project>
|
||
```
|
||
|
||
- [ ] **Step 3: csc 编译验证(0 错误)**
|
||
|
||
Run: `dotnet build "Client/HotUpdateGames/RPS.Client/RPS.Client.csproj" -c Release -nologo`
|
||
Expected: `Build succeeded`,0 Error。若报 `UnityEngine` 找不到,确认本机 `D:\Program Files\Unity 2022.3.62f2c1\Editor` 存在(与 `HelloFlow.Client.csproj` 同路径);路径不同则传 `-p:UnityEditorPath=<你的Unity路径>`。
|
||
|
||
- [ ] **Step 4: 确认未把框架/Core/Unity DLL 拷进输出(Private=false 生效)**
|
||
|
||
Run: `ls Client/HotUpdateGames/RPS.Client/bin/Release/netstandard2.1/`
|
||
Expected: 只有 `RPS.Client.dll`(及 pdb/deps.json),无 `XWorld.Framework.Shared.dll`、`RPS.Core.dll`、`UnityEngine*.dll`。
|
||
|
||
> 备注:`RpsGameClient.cs` 在 Unity 中需要 `.meta`,由 Unity 导入时自动生成,本环境不手工创建。**仍须 Unity 人工核对**:运行时表现、HybridCLR 加载、AB 打包。
|
||
|
||
---
|
||
|
||
## Task 13: 删除弃用 Lua + 更新 README
|
||
|
||
**Files:**
|
||
- Delete: `Client/Assets/MiniGames/RockPaperScissors/Client/main.lua`(+`.meta`)、`.../Server/main.lua`(+`.meta`)、`.../scripts/Client/main.lua`(+`.meta`)、`.../scripts/Server/main.lua`(+`.meta`)
|
||
- Modify: `Client/Assets/MiniGames/RockPaperScissors/README.md:15-22`
|
||
|
||
- [ ] **Step 1: 删除四份 Lua 及其 .meta,并清理空目录**
|
||
|
||
Run:
|
||
```bash
|
||
cd "Client/Assets/MiniGames/RockPaperScissors"
|
||
rm -f Client/main.lua Client/main.lua.meta Server/main.lua Server/main.lua.meta
|
||
rm -f scripts/Server/main.lua scripts/Server/main.lua.meta
|
||
rm -rf Client Client.meta Server Server.meta scripts/Server scripts/Server.meta
|
||
cd -
|
||
```
|
||
(注:`scripts/Client/main.lua` 不在此删 —— 下一步会被 Task 12 的 `RpsGameClient.cs` 取代;若仍存在则一并删。)
|
||
|
||
- [ ] **Step 2: 删除残留的 scripts/Client/main.lua(C# 已取代)**
|
||
|
||
Run:
|
||
```bash
|
||
rm -f "Client/Assets/MiniGames/RockPaperScissors/scripts/Client/main.lua" \
|
||
"Client/Assets/MiniGames/RockPaperScissors/scripts/Client/main.lua.meta"
|
||
```
|
||
|
||
- [ ] **Step 3: 替换 README 的"当前状态"小节(原 15-22 行)为:**
|
||
|
||
```markdown
|
||
## 当前状态(C# 客户端已落地)
|
||
|
||
- `res/UI/Texture/`:三张出招贴图(rock / paper / scissors),将随 AB 打包。
|
||
- `scripts/Client/`:客户端薄层 C# 实现 `RpsGameClient`(`IGameClient`),由 Unity/HybridCLR 编译为热更 DLL;
|
||
另有 `Client/HotUpdateGames/RPS.Client/RPS.Client.csproj` 链接同一份源做 csc 编译验证。
|
||
- 共享核心 `RPS.Core`(规则/状态/编解码)与服务端薄层 `RPS.Server`(`IGameServerRoom`)的 C# 源码在
|
||
`Server/games-src/RPS/`(旧 Lua 版已删除)。
|
||
- 待办(独立改造,须 Unity):将 `RPS.Core` 收敛为 `scripts/Core` 单源、服务端 `<Compile Include>` 链接(设计 §10.1)。
|
||
```
|
||
|
||
- [ ] **Step 4: 确认 Lua 已清除**
|
||
|
||
Run: `find "Client/Assets/MiniGames/RockPaperScissors" -name "*.lua"`
|
||
Expected: 无输出。
|
||
|
||
---
|
||
|
||
## Task 14: PC 烟雾测试改用 RPS
|
||
|
||
**Files:**
|
||
- Modify: `Tools/PcMiniGameSmoke/Build-PcMiniGameSmoke.ps1`(整文件替换)
|
||
- Modify: `Client/Assets/Script/xmain/MiniGame/PcMiniGameSmokeLauncher.cs:7,13,48`
|
||
- Modify: `Client/Assets/Script/Editor/PcMiniGameSmokeMenu.cs:30`
|
||
|
||
- [ ] **Step 1: 用以下内容整体替换 `Tools/PcMiniGameSmoke/Build-PcMiniGameSmoke.ps1`**
|
||
|
||
```powershell
|
||
param(
|
||
[string]$UnityEditorPath = "D:\Program Files\Unity 2022.3.62f2c1\Editor",
|
||
[string]$OutRoot = "Build\PcMiniGameSmoke"
|
||
)
|
||
|
||
$ErrorActionPreference = "Stop"
|
||
|
||
function Md5Hex([string]$Path) {
|
||
$md5 = [System.Security.Cryptography.MD5]::Create()
|
||
try {
|
||
$bytes = [System.IO.File]::ReadAllBytes((Resolve-Path $Path))
|
||
$hash = $md5.ComputeHash($bytes)
|
||
return -join ($hash | ForEach-Object { $_.ToString("x2") })
|
||
}
|
||
finally {
|
||
$md5.Dispose()
|
||
}
|
||
}
|
||
|
||
function WriteMd5Named([string]$Source, [string]$LogicalName, [string]$DestDir) {
|
||
$hash = Md5Hex $Source
|
||
$ext = [System.IO.Path]::GetExtension($LogicalName)
|
||
$nameNoExt = if ($ext.Length -gt 0) { $LogicalName.Substring(0, $LogicalName.Length - $ext.Length) } else { $LogicalName }
|
||
$destName = if ($ext.Length -gt 0) { "${nameNoExt}_${hash}${ext}" } else { "${LogicalName}_${hash}" }
|
||
Copy-Item -LiteralPath $Source -Destination (Join-Path $DestDir $destName) -Force
|
||
$size = (Get-Item -LiteralPath $Source).Length
|
||
return "{'$LogicalName','$hash',$size},"
|
||
}
|
||
|
||
$repo = (Resolve-Path (Join-Path $PSScriptRoot "..\..")).Path
|
||
$out = Join-Path $repo $OutRoot
|
||
$serverDir = Join-Path $out "server\games\rps\1"
|
||
$clientDir = Join-Path $out "client\minigame\rps\1"
|
||
$stageDir = Join-Path $out "_stage"
|
||
|
||
New-Item -ItemType Directory -Force -Path $serverDir, $clientDir, $stageDir | Out-Null
|
||
foreach ($dir in @($serverDir, $clientDir, $stageDir)) {
|
||
Get-ChildItem -LiteralPath $dir -Force | Remove-Item -Recurse -Force
|
||
}
|
||
|
||
dotnet build (Join-Path $repo "Server\games-src\RPS\RPS.Server\RPS.Server.csproj") -c Release -nologo | Write-Host
|
||
dotnet build (Join-Path $repo "Client\HotUpdateGames\RPS.Client\RPS.Client.csproj") -c Release -nologo -p:UnityEditorPath="$UnityEditorPath" | Write-Host
|
||
|
||
$rpsCore = Join-Path $repo "Server\games-src\RPS\RPS.Server\bin\Release\netstandard2.1\RPS.Core.dll"
|
||
$rpsServer = Join-Path $repo "Server\games-src\RPS\RPS.Server\bin\Release\netstandard2.1\RPS.Server.dll"
|
||
$rpsClient = Join-Path $repo "Client\HotUpdateGames\RPS.Client\bin\Release\netstandard2.1\RPS.Client.dll"
|
||
|
||
Copy-Item -LiteralPath $rpsCore -Destination (Join-Path $serverDir "RPS.Core.dll") -Force
|
||
Copy-Item -LiteralPath $rpsServer -Destination (Join-Path $serverDir "RPS.Server.dll") -Force
|
||
|
||
@"
|
||
{
|
||
"gameId": "rps",
|
||
"version": 1,
|
||
"serverAssembly": "RPS.Server.dll",
|
||
"serverEntryType": "RPS.Server.RpsServerRoom",
|
||
"minFrameworkVersion": 1,
|
||
"playerCount": 2,
|
||
"tickRateHz": 10
|
||
}
|
||
"@ | Set-Content -Encoding UTF8 (Join-Path $serverDir "game.json")
|
||
|
||
@"
|
||
{
|
||
"gameId": "rps",
|
||
"version": 1,
|
||
"clientEntryType": "RPS.Client.RpsGameClient",
|
||
"coreDll": "RPS.Core.dll",
|
||
"clientDll": "RPS.Client.dll",
|
||
"minFrameworkVersion": 1,
|
||
"assets": []
|
||
}
|
||
"@ | Set-Content -Encoding UTF8 (Join-Path $clientDir "game.json")
|
||
|
||
$coreBytes = Join-Path $stageDir "RPS.Core.dll.bytes"
|
||
$clientBytes = Join-Path $stageDir "RPS.Client.dll.bytes"
|
||
Copy-Item -LiteralPath $rpsCore -Destination $coreBytes -Force
|
||
Copy-Item -LiteralPath $rpsClient -Destination $clientBytes -Force
|
||
|
||
$lines = @()
|
||
$lines += WriteMd5Named $coreBytes "RPS.Core.dll.bytes" $clientDir
|
||
$lines += WriteMd5Named $clientBytes "RPS.Client.dll.bytes" $clientDir
|
||
$lines -join "`n" | Set-Content -Encoding UTF8 (Join-Path $clientDir "files.txt")
|
||
Set-Content -Encoding Byte -Value @() (Join-Path $clientDir "files.txt.sig")
|
||
|
||
Write-Host "PC smoke package ready:"
|
||
Write-Host " server games root: $((Resolve-Path (Join-Path $out 'server\games')).Path)"
|
||
Write-Host " client cdn base: file:///$($repo.Replace('\','/'))/$($OutRoot.Replace('\','/'))/client/minigame/"
|
||
Write-Host "Run server (rps 为 2 人,单人入房需 AI 兜底,故用较小 matchTimeoutTicks):"
|
||
Write-Host " dotnet Server\Gateway.Runner\bin\Debug\net10.0\XWorld.Server.Gateway.Runner.dll --gamesRoot `"$((Resolve-Path (Join-Path $out 'server\games')).Path)`" --port 5005 --tickMs 100 --matchTimeoutTicks 30"
|
||
```
|
||
|
||
- [ ] **Step 2: 改 `PcMiniGameSmokeLauncher.cs` 第 7 行注释**
|
||
|
||
把:
|
||
```csharp
|
||
// PC 本地端到端烟测入口:连接本地 Gateway,再用 MiniGameHost 下载/加载 hello 小游戏。
|
||
```
|
||
改为:
|
||
```csharp
|
||
// PC 本地端到端烟测入口:连接本地 Gateway,再用 MiniGameHost 下载/加载 rps 小游戏。
|
||
```
|
||
|
||
- [ ] **Step 3: 改第 13 行默认 GameId**
|
||
|
||
把:
|
||
```csharp
|
||
public string GameId = "hello";
|
||
```
|
||
改为:
|
||
```csharp
|
||
public string GameId = "rps";
|
||
```
|
||
|
||
- [ ] **Step 4: 改第 48 行 Expected 文案**
|
||
|
||
把:
|
||
```csharp
|
||
GUILayout.Label("Expected: download hello DLLs -> connect -> MatchFound -> 3 snapshots -> RoomEnd.");
|
||
```
|
||
改为:
|
||
```csharp
|
||
GUILayout.Label("Expected: download rps DLLs -> connect -> MatchFound(+AI) -> snapshots -> RoomEnd.");
|
||
```
|
||
|
||
- [ ] **Step 4b: 改 `Client/Assets/Script/Editor/PcMiniGameSmokeMenu.cs` 第 30 行默认 GameId**
|
||
|
||
把:
|
||
```csharp
|
||
launcher.GameId = "hello";
|
||
```
|
||
改为:
|
||
```csharp
|
||
launcher.GameId = "rps";
|
||
```
|
||
|
||
- [ ] **Step 5: 验证 .NET 构建段(Unity/AB/Play 段无法在本环境验)**
|
||
|
||
Run: `dotnet build "Client/HotUpdateGames/RPS.Client/RPS.Client.csproj" -c Release -nologo && dotnet build "Server/games-src/RPS/RPS.Server/RPS.Server.csproj" -c Release -nologo`
|
||
Expected: 两个 `Build succeeded`,0 Error。
|
||
> **须人工核对**:在 Windows + Unity 下运行 `Build-PcMiniGameSmoke.ps1` 与 Play 烟测(本环境无法执行 PowerShell/Unity)。
|
||
|
||
---
|
||
|
||
## Task 15: GameManifest.cs 文档注释改 RPS
|
||
|
||
**Files:**
|
||
- Modify: `Server/Server.Host/GameManifest.cs:10-11`
|
||
|
||
- [ ] **Step 1: 替换第 10-11 行注释**
|
||
|
||
把:
|
||
```csharp
|
||
public string ServerAssembly { get; set; } // 例如 "Hello.Server.dll"
|
||
public string ServerEntryType { get; set; } // 例如 "Hello.Server.HelloServerRoom"
|
||
```
|
||
改为:
|
||
```csharp
|
||
public string ServerAssembly { get; set; } // 例如 "RPS.Server.dll"
|
||
public string ServerEntryType { get; set; } // 例如 "RPS.Server.RpsServerRoom"
|
||
```
|
||
|
||
- [ ] **Step 2: 确认 Server.Host 仍编译**
|
||
|
||
Run: `dotnet build Server/Server.Host/Server.Host.csproj -c Debug -nologo`
|
||
Expected: `Build succeeded`。
|
||
|
||
---
|
||
|
||
## Task 16: 最终全量回归 + Hello 痕迹清零核对
|
||
|
||
**Files:** 无(仅运行)
|
||
|
||
- [ ] **Step 1: 全量测试**
|
||
|
||
Run: `dotnet test Server/Server.sln`
|
||
Expected: 全部 PASS,0 失败。
|
||
|
||
- [ ] **Step 2: 确认本项目代码内已无 Hello 引用(只扫我们维护的目录,排除第三方 Unity 包/无关示例串)**
|
||
|
||
Run:
|
||
```bash
|
||
grep -rIln "[Hh]ello\|HELLO" \
|
||
Server/Server.Host Server/Gateway Server/games-src Server/Server.Host.Tests Server/Gateway.Tests \
|
||
Server/build-test-games.sh Tools/PcMiniGameSmoke \
|
||
Client/HotUpdateGames Client/Assets/MiniGames \
|
||
Client/Assets/Script/xmain/MiniGame Client/Assets/Script/Editor \
|
||
2>/dev/null | grep -v "/bin/" | grep -v "/obj/" || echo "no hello references"
|
||
```
|
||
Expected: `no hello references`。
|
||
> 说明:仓库其他位置(`Client/Library/PackageCache`、`Client/PackageLocal`、`AES.cs`、`WX.cs`、`lsocket.cs`、`XWMain.lua`、`sandbox_spec.lua` 等)出现的 "hello" 是第三方 Unity 包测试或无关示例字符串,不在本计划范围。
|
||
|
||
- [ ] **Step 3: 确认 RPS C# 客户端编译通过**
|
||
|
||
Run: `dotnet build "Client/HotUpdateGames/RPS.Client/RPS.Client.csproj" -c Release -nologo`
|
||
Expected: `Build succeeded`,0 Error。
|
||
|
||
---
|
||
|
||
## 完成标准
|
||
|
||
- `dotnet test Server/Server.sln` 全绿(0 失败)。
|
||
- `Client/HotUpdateGames/RPS.Client/RPS.Client.csproj` csc 编译 0 错误。
|
||
- 仓库内无 Hello 源码、夹具、测试引用、构建脚本引用(文档目录除外)。
|
||
- 弃用 Lua 已删,README 已更新。
|
||
- PC 烟雾的 .NET 段构建通过;Unity Play/AB 段标注为人工核对。
|
||
|
||
## 遗留(本环境无 Unity,无法验证)
|
||
|
||
- RPS C# 客户端运行时表现、HybridCLR 加载、AB 打包。
|
||
- `Build-PcMiniGameSmoke.ps1` 实际运行 + Play 烟测。
|
||
- `RPS.Core` 收敛为 `scripts/Core` 单源(设计 §10.1,独立改造项)。
|