# 删除 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" < $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( () => 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(() => 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(() => 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 Broadcasts = new List(); 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 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( () => 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(); 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 Frames(FakeConnection c) { var list = new List(); 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 ``` 改为: ```xml ``` - [ ] **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 同级)用 `` 链接同一批源做 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(); _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 netstandard2.1 9.0 disable disable RPS.Client RPS.Client false D:\Program Files\Unity 2022.3.62f2c1\Editor false false $(UnityEditorPath)\Data\Managed\UnityEngine\UnityEngine.dll false $(UnityEditorPath)\Data\Managed\UnityEngine\UnityEngine.CoreModule.dll false $(UnityEditorPath)\Data\Managed\UnityEngine\UnityEngine.IMGUIModule.dll false ``` - [ ] **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` 单源、服务端 `` 链接(设计 §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,独立改造项)。